Compare commits

...

129 Commits

Author SHA1 Message Date
Luis Pater
a3dc56d2a0 Merge branch 'router-for-me:main' into main 2026-02-09 02:07:02 +08:00
Luis Pater
63643c44a1 Fixed: #1484
fix(translator): restructure message content handling to support multiple content types

- Consolidated `input_text` and `output_text` handling into a single case.
- Added support for processing `input_image` content with associated URLs.
2026-02-09 02:05:38 +08:00
Luis Pater
1d93608dbe Merge pull request #203 from JokerRun/fix/copilot-premium-usage-inflation
fix(copilot): prevent premium request count inflation for Claude models
2026-02-08 20:42:51 +08:00
Luis Pater
d125b7de92 Merge pull request #199 from ravindrabarthwal/add-claude-opus-4.6-github-copilot
feat: add Claude Opus 4.6 to GitHub Copilot models
2026-02-08 20:41:20 +08:00
Luis Pater
d5654ee316 Merge branch 'router-for-me:main' into main 2026-02-08 20:40:18 +08:00
Luis Pater
3b34521ad9 Merge pull request #1479 from router-for-me/management
refactor(management): streamline control panel management and implement sync throttling
2026-02-08 20:37:29 +08:00
hkfires
7197fb350b fix(config): prune default descendants when merging new yaml nodes 2026-02-08 19:05:52 +08:00
hkfires
6e349bfcc7 fix(config): avoid writing known defaults during merge 2026-02-08 18:47:44 +08:00
hkfires
234056072d refactor(management): streamline control panel management and implement sync throttling 2026-02-08 10:42:49 +08:00
rico
76330f4bff feat(copilot): add Claude Opus 4.6 model definition
> 添加 copilot claude opus 4.6 支持 (ref: PR #199)
2026-02-08 02:38:06 +08:00
rico
d468eec6ec fix(copilot): prevent premium request count inflation for Claude models
> Copilot Premium usage significantly amplified when using amp

- Add X-Initiator header (user/agent) based on last message role to
  prevent Copilot from billing all requests as premium user-initiated
- Add flattenAssistantContent() to convert assistant content from array
  to string, preventing Claude from re-answering all previous prompts
- Align Copilot headers (User-Agent, Editor-Version, Openai-Intent) with
  pi-ai reference implementation

Closes #113

Amp-Thread-ID: https://ampcode.com/threads/T-019c392b-736e-7489-a06b-f94f7c75f7c0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-08 02:22:10 +08:00
Ravindra Barthwal
9bc6cc5b41 feat: add Claude Opus 4.6 to GitHub Copilot models
GitHub Copilot now supports claude-opus-4.6 but it was missing from
the proxy's model definitions. Fixes #196.
2026-02-07 14:58:34 +05:30
Luis Pater
d109be159c Merge pull request #197 from router-for-me/plus
v6.8.4
2026-02-07 09:37:04 +08:00
Luis Pater
eddf31e55b Merge branch 'main' into plus 2026-02-07 09:36:52 +08:00
Luis Pater
7e9d0db6aa Merge pull request #1467 from dusty-du/fix/kimi-toolcall-reasoning-content
Fix Kimi tool-call payload normalization for reasoning_content
2026-02-07 09:35:04 +08:00
Luis Pater
2f1874ede5 chore(docs): remove Cubence sponsorship from README files and delete related asset 2026-02-07 08:55:14 +08:00
Luis Pater
6b83585b53 Merge branch 'router-for-me:main' into main 2026-02-07 08:52:51 +08:00
Luis Pater
78ef04fcf1 fix(kimi): reduce redundant payload cloning and simplify translation calls 2026-02-07 08:51:48 +08:00
hkfires
b7e4f00c5f fix(translator): correct gemini-cli log prefix 2026-02-07 08:40:09 +08:00
Luis Pater
c20507c15e Merge branch 'router-for-me:main' into main 2026-02-07 06:43:17 +08:00
Luis Pater
f7d0019df7 fix(kimi): update base URL and integrate ClaudeExecutor fallback
- Updated `KimiAPIBaseURL` to remove versioning from the root path.
- Integrated `ClaudeExecutor` fallback in `KimiExecutor` methods for compatibility with Claude requests.
- Simplified token counting by delegating to `ClaudeExecutor`.
2026-02-07 06:42:08 +08:00
test
52364af5bf Fix Kimi tool-call reasoning_content normalization 2026-02-06 14:46:16 -05:00
Luis Pater
f410dd0440 Merge pull request #1390 from sususu98/fix/400-invalid-request-no-retry
fix(auth): 400 invalid_request_error 立即返回不再重试
2026-02-07 03:14:25 +08:00
Luis Pater
eb5582c17c Merge pull request #1386 from shenshuoyaoyouguang/sync-auth-changes
fix(auth): normalize model key for thinking suffix in selectors
2026-02-07 03:12:01 +08:00
Luis Pater
1c6cb2bec3 Merge pull request #1239 from ThanhNguyxn/fix/gitstore-gc-after-squash
fix(store): run GC after squashing history to prevent loose object accumulation
2026-02-07 02:51:27 +08:00
Luis Pater
80b5e79e75 fix(translator): normalize and restrict stop_reason/finish_reason usage
- Standardized the handling of `stop_reason` and `finish_reason` across Codex and Gemini responses.
- Restricted pass-through of specific reasons (`max_tokens`, `stop`) for consistency.
- Enhanced fallback logic for undefined reasons.
2026-02-07 02:07:51 +08:00
Luis Pater
d182e893b6 Merge pull request #194 from PancakeZik/fix/assistant-content-parroting
fix: replace assistant placeholder text to prevent model parroting
2026-02-07 01:38:58 +08:00
Luis Pater
2e8d49a641 Merge pull request #191 from CheesesNguyen/feat/kiro-api-models-and-context-usage
feat(kiro): add contextUsageEvent handler
2026-02-07 01:33:49 +08:00
Luis Pater
6abd7d27d9 Merge pull request #190 from taetaetae/fix/kiro-claude-compaction-current-user-empty-content
fix(kiro): handle empty content in current user message for compaction
2026-02-07 01:33:01 +08:00
Luis Pater
8fa12af403 Merge pull request #195 from router-for-me/plus
v6.8.1
2026-02-07 01:31:40 +08:00
Luis Pater
77586ed7d3 Merge branch 'main' into plus 2026-02-07 01:31:21 +08:00
Luis Pater
394497fb2f Merge pull request #1465 from router-for-me/kimi-fix
fix(kimi): add OAuth model-alias channel support and cover OAuth excl…
2026-02-07 01:27:30 +08:00
LTbinglingfeng
fc7b6ef086 fix(kimi): add OAuth model-alias channel support and cover OAuth excluded-models with tests 2026-02-07 01:16:39 +08:00
Joao
98edcad39d fix: replace assistant placeholder text to prevent model parroting
Kiro API requires non-empty content on assistant messages, so
CLIProxyAPI injects placeholder text when assistant messages only
contain tool_use blocks (no text). The previous placeholders were
conversational phrases:

- DefaultAssistantContentWithTools: "I'll help you with that."
- DefaultAssistantContent: "I understand."

In agentic sessions with many tool calls, these phrases appeared
dozens of times in conversation history. Opus 4.6 (and likely other
models) picked up on this pattern and started parroting "I'll help
you with that." before every tool call in its actual responses.

Fix: Replace both placeholders with a single dot ".", which
satisfies Kiro's non-empty requirement without giving the model
a phrase to mimic.
2026-02-06 16:42:21 +00:00
Luis Pater
1187aa8222 feat(translator): capture cached token count in usage metadata and handle prompt caching
- Added support to extract and include `cachedContentTokenCount` in `usage.prompt_tokens_details`.
- Logged warnings for failures to set cached token count for better debugging.
2026-02-06 21:28:40 +08:00
Luis Pater
a35d66443b Merge pull request #192 from router-for-me/plus
v6.8.0
2026-02-06 21:04:40 +08:00
Luis Pater
40ad4a42ea Merge branch 'main' into plus 2026-02-06 21:04:32 +08:00
Luis Pater
dc9b4dd017 Merge branch 'kimi-provider-support-v2' into dev 2026-02-06 20:51:48 +08:00
Luis Pater
68cb81a258 feat: add Kimi authentication support and streamline device ID handling
- Introduced `RequestKimiToken` API for Kimi authentication flow.
- Integrated device ID management throughout Kimi-related components.
- Enhanced header management for Kimi API requests with device ID context.
2026-02-06 20:43:30 +08:00
CheesesNguyen
16693053f5 feat(kiro): add contextUsageEvent handler and simplify model structs
- Add contextUsageEvent case handler in kiro_executor.go for both
  parseEventStream and streamToChannel functions
- Handle nested format: {"contextUsageEvent": {"contextUsagePercentage": 0.53}}
- Keep KiroModel struct minimal with only essential fields
- Remove unused KiroPromptCachingInfo struct from kiro_model_converter.go
- Remove unused SupportedInputTypes and PromptCaching fields from KiroAPIModel
2026-02-06 11:12:27 +07:00
taetaetae
4e3bad3907 fix(kiro): handle empty content in current user message for compaction
Problem:
- PR #186 fixed empty content for assistant messages and history user messages
- But current user message (isLastMessage == true) was not fixed
- When user message contains only tool_result (no text), content becomes empty
- This causes 'Improperly formed request' errors from Kiro API
- Compaction requests from OpenCode commonly have this pattern

Solution:
- Move empty content check BEFORE the isLastMessage branch
- Apply fallback content to ALL user messages, not just history
- Add DefaultUserContentWithToolResults and DefaultUserContent constants

Fixes compaction failures for OpenCode + Quotio + CLIProxyAPIPlus + Kiro stack
2026-02-06 11:58:43 +09:00
hkfires
c874f19f2a refactor(config): disable automatic migration during server startup 2026-02-06 09:57:47 +08:00
test
f5f26f0cbe Add Kimi (Moonshot AI) provider support
- OAuth2 device authorization grant flow (RFC 8628) for authentication
- Streaming and non-streaming chat completions via OpenAI-compatible API
- Models: kimi-k2, kimi-k2-thinking, kimi-k2.5
- CLI `--kimi-login` command for device flow auth
- Token management with automatic refresh
- Thinking/reasoning effort support for thinking-enabled models

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 19:24:46 -05:00
Luis Pater
e7e3ca1efb Merge branch 'router-for-me:main' into main 2026-02-06 05:45:21 +08:00
Luis Pater
4b00312fef Merge pull request #1435 from tianyicui/fix/haiku-4-5-thinking-support
fix: Enable extended thinking support for Claude Haiku 4.5
2026-02-06 05:44:14 +08:00
Luis Pater
c5fd3db01e Merge pull request #1446 from qyhfrank/fix-claude-opus-4-6-model-metadata
fix(registry): correct Claude Opus 4.6 model metadata
2026-02-06 05:43:32 +08:00
Luis Pater
e35ffaa925 Merge pull request #186 from taetaetae/fix/kiro-claude-compaction-empty-content
fix(kiro): handle empty content in Claude format assistant messages
2026-02-06 05:39:41 +08:00
Frank Qing
f870a9d2a7 fix(registry): correct Claude Opus 4.6 model metadata 2026-02-06 05:39:41 +08:00
Luis Pater
165e03f3a7 Merge branch 'router-for-me:main' into main 2026-02-06 05:32:10 +08:00
Luis Pater
86bdb7808c Merge pull request #189 from PancakeZik/main
feat: add Claude Opus 4.6 support for Kiro
2026-02-06 05:31:54 +08:00
Luis Pater
b4e034be1c refactor(executor): centralize Codex client version and user agent constants
- Introduced `codexClientVersion` and `codexUserAgent` constants for better maintainability.
- Updated `EnsureHeader` calls to use the new constants.
2026-02-06 05:30:28 +08:00
Joao
84fcebf538 feat: add Claude Opus 4.6 support for Kiro
- Add kiro-claude-opus-4-6 and kiro-claude-opus-4-6-agentic to model registry
- Add model ID mappings for claude-opus-4.6 variants
- Support both kiro- prefix and native format (claude-opus-4.6)
- Tested and working with Kiro API
2026-02-05 21:26:29 +00:00
Luis Pater
74d9a1ffed Merge branch 'router-for-me:main' into main 2026-02-06 03:27:27 +08:00
Luis Pater
a5a25dec57 refactor(translator, executor): remove redundant bytes.Clone calls for improved performance
- Replaced all instances of `bytes.Clone` with direct references to enhance efficiency.
- Simplified payload handling across executors and translators by eliminating unnecessary data duplication.
2026-02-06 03:26:29 +08:00
Luis Pater
c71905e5e8 Merge pull request #1440 from kvokka/add-cc-opus-4-6
feat(registry): register Claude 4.6 static data
2026-02-06 03:23:59 +08:00
kvokka
bc78d668ac feat(registry): register Claude 4.6 static data
Add model definition for Claude 4.6 Opus with 200k context length and thinking support capabilities.
2026-02-05 23:13:36 +04:00
Luis Pater
e93eebc2e9 Merge branch 'router-for-me:main' into main 2026-02-06 01:53:55 +08:00
Luis Pater
5bd0896ad7 feat(registry): add GPT 5.3 Codex model to static data 2026-02-06 01:52:41 +08:00
Luis Pater
09ecfbcaed refactor(executor): optimize payload cloning and streamline SDK translator usage
- Replaced unnecessary `bytes.Clone` calls for `opts.OriginalRequest` throughout executors.
- Introduced intermediate variable `originalPayloadSource` to simplify payload processing.
- Ensured better clarity and structure in request translation logic.
2026-02-06 01:44:20 +08:00
Luis Pater
f0bd14b64f refactor(util): optimize JSON schema processing and keyword removal logic
- Consolidated path-finding logic into a new `findPathsByFields` helper function.
- Refactored repetitive loop structures to improve readability and performance.
- Added depth-based sorting for deletion paths to ensure proper removal order.
2026-02-06 00:19:56 +08:00
taetaetae
14f044ce4f refactor: extract default assistant content to shared constants
Apply code review feedback from gemini-code-assist:
- Move fallback strings to kirocommon package as exported constants
- Update kiro_claude_request.go to use shared constants
- Update kiro_openai_request.go to use shared constants
- Improves maintainability and avoids duplication
2026-02-05 23:36:57 +09:00
taetaetae
88872baffc fix(kiro): handle empty content in Claude format assistant messages
Problem:
- PR #181 fixed empty content for OpenAI format (kiro_openai_request.go)
- But Claude format (kiro_claude_request.go) was not fixed
- OpenCode uses Claude format (/v1/messages endpoint)
- When assistant messages have only tool_use (no text), content becomes empty
- This causes 'Improperly formed request' errors from Kiro API

Example of problematic message format:
{
  "role": "assistant",
  "content": [
    {"type": "tool_use", "id": "...", "name": "todowrite", "input": {...}}
  ]
}

Solution:
- Add empty content fallback in BuildAssistantMessageStruct (Claude format)
- Same fix as PR #181 applied to kiro_openai_request.go

Fixes compaction failures for OpenCode + Quotio + CLIProxyAPIPlus + Kiro stack
2026-02-05 23:27:35 +09:00
Luis Pater
dbecf5330e Merge pull request #181 from taetaetae/fix/kiro-compaction-tool-use-content
fix(kiro): handle tool_use in content array for compaction requests
2026-02-05 20:17:32 +08:00
Luis Pater
1c0e102637 Merge pull request #185 from router-for-me/plus
v6.7.48
2026-02-05 19:53:42 +08:00
Luis Pater
6b6b343922 Merge branch 'main' into plus 2026-02-05 19:51:56 +08:00
Luis Pater
f7d82fda3f feat(registry): add Kimi-K2.5 model to static data 2026-02-05 19:48:04 +08:00
Tianyi Cui
706590c62a fix: Enable extended thinking support for Claude Haiku 4.5
Claude Haiku 4.5 (claude-haiku-4-5-20251001) supports extended thinking
according to Anthropic's official documentation:
https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking

The model was incorrectly marked as not supporting thinking in the static
model definitions. This fix adds ThinkingSupport with the same parameters
as other Claude 4.5 models (Sonnet, Opus):
- Min: 1024 tokens
- Max: 128000 tokens
- ZeroAllowed: true
- DynamicAllowed: false
2026-02-05 19:03:23 +08:00
Luis Pater
25c6b479c7 refactor(util, executor): optimize payload handling and schema processing
- Replaced repetitive string operations with a centralized `escapeGJSONPathKey` function.
- Streamlined handling of JSON schema cleaning for Gemini and Antigravity requests.
- Improved payload management by transitioning from byte slices to strings for processing.
- Removed unnecessary cloning of byte slices in several places.
2026-02-05 19:00:30 +08:00
Chén Mù
7cf9ff0345 Merge pull request #1429 from neavo/fix/gemini-python-sdk-thinking-fields
fix(gemini): support snake_case thinking config fields from Python SDK
2026-02-05 14:32:58 +08:00
hkfires
209d74062a fix(thinking): ensure includeThoughts is false for ModeNone in budget processing 2026-02-05 10:24:42 +08:00
hkfires
d86b13c9cb fix(thinking): support user-defined includeThoughts setting with camelCase and snake_case variants
Fixes #1378
2026-02-05 10:07:41 +08:00
hkfires
075e3ab69e fix(test): rename test function to reflect behavior change for builtin tools 2026-02-05 09:25:34 +08:00
taetaetae
49ef22ab78 refactor: simplify inputMap initialization logic
Apply code review feedback from gemini-code-assist:
- Initialize inputMap upfront instead of using nested if blocks
- Combine Exists() and IsObject() checks into single condition
- Remove redundant nil check
2026-02-05 07:12:42 +09:00
taetaetae
ae4638712e fix(kiro): handle tool_use in content array for compaction requests
Problem:
- PR #162 fixed empty string content but missed array content with tool_use
- OpenCode's compaction requests send assistant messages with content as array
- When content array contains only tool_use (no text), content becomes empty
- This causes 'Improperly formed request' errors from Kiro API

Example of problematic message format:
{
  "role": "assistant",
  "content": [
    {"type": "tool_use", "id": "...", "name": "todowrite", "input": {...}}
  ]
}

Solution:
- Extract tool_use from content array (Anthropic/OpenCode format)
- This is in addition to existing tool_calls handling (OpenAI format)
- The empty content fallback from PR #162 will then work correctly

Fixes compaction failures that persisted after PR #162 merge.
2026-02-05 07:08:14 +09:00
Luis Pater
c1c9483752 Merge pull request #1422 from dannycreations/feat-gemini-cli-claude-mime
feat(gemini-cli): support image content in Claude request conversion
2026-02-05 01:21:09 +08:00
neavo
6c65fdf54b fix(gemini): support snake_case thinking config fields from Python SDK
Google official Gemini Python SDK sends thinking_level, thinking_budget,
and include_thoughts (snake_case) instead of thinkingLevel, thinkingBudget,
and includeThoughts (camelCase). This caused thinking configuration to be
ignored when using Python SDK.

Changes:
- Extract layer: extractGeminiConfig now reads snake_case as fallback
- Apply layer: Gemini/CLI/Antigravity appliers clean up snake_case fields
- Translator layer: Gemini->OpenAI/Claude/Codex translators support fallback
- Tests: Added 4 test cases for snake_case field coverage

Fixes #1426
2026-02-04 21:12:47 +08:00
Luis Pater
4874253d1e Merge pull request #1425 from router-for-me/auth
fix(cliproxy): update auth before model registration
2026-02-04 15:01:01 +08:00
Luis Pater
b72250349f Merge pull request #1423 from router-for-me/watcher
feat(watcher): log auth field changes on reload
2026-02-04 15:00:38 +08:00
hkfires
116573311f fix(cliproxy): update auth before model registration 2026-02-04 14:03:15 +08:00
hkfires
4af712544d feat(watcher): log auth field changes on reload
Cache parsed auth contents and compute redacted diffs for prefix, proxy_url,
and disabled when auth files are added or updated.
2026-02-04 12:29:56 +08:00
dannycreations
3f9c9591bd feat(gemini-cli): support image content in Claude request conversion
- Add logic to handle `image` content type during request translation.
- Map Claude base64 image data to Gemini's `inlineData` structure.
- Support automatic extraction of `media_type` and `data` for image parts.
2026-02-04 11:00:37 +07:00
Luis Pater
1548c567ab feat(pprof): add support for configurable pprof HTTP debug server
- Introduced a new `pprof` server to enable/debug HTTP profiling.
- Added configuration options for enabling/disabling and specifying the server address.
- Integrated pprof server lifecycle management with `Service`.

#1287
2026-02-04 02:39:26 +08:00
Luis Pater
5b23fc570c Merge pull request #1396 from Xm798/fix/log-dir-tilde-expansion
fix(logging): expand tilde in auth-dir path for log directory
2026-02-04 02:00:13 +08:00
Luis Pater
04e1c7a05a docs: reorganize and update README entries for CLIProxyAPI projects 2026-02-04 01:49:27 +08:00
Luis Pater
9181e72204 Merge pull request #1409 from wangdabaoqq/main
docs: Add a new client application - Lin Jun
2026-02-04 01:47:31 +08:00
Luis Pater
b854ee4680 fix(registry): remove redundant kiro model definition entry 2026-02-04 01:28:12 +08:00
Luis Pater
533a6bd15c Merge pull request #176 from router-for-me/plus
v6.7.45
2026-02-04 01:25:54 +08:00
Luis Pater
45546c1cf7 Merge branch 'main' into plus 2026-02-04 01:25:45 +08:00
Luis Pater
e2169e3987 Merge pull request #175 from Skyuno/fix/json-truncation-rework
fix(kiro): Rework JSON Truncation Handling with SOFT_LIMIT_REACHED
2026-02-04 01:24:35 +08:00
Luis Pater
e85305c815 Merge pull request #174 from gogoing1024/main
feat(registry): add kiro channel support for model definitions
2026-02-04 01:08:43 +08:00
Luis Pater
8d4554bf17 Merge pull request #173 from starsdream666/main
修复:docker镜像上传时用户名使用变量并增加手动构建,修复OAuth 排除列表与OAuth 模型别名中kiro无法获取模型问题
2026-02-04 01:06:49 +08:00
Luis Pater
f628e4dcbb Merge pull request #172 from Skyuno/fix/idc-filename-collision
fix(kiro): prioritize email for filename to prevent collisions
2026-02-04 01:04:33 +08:00
Luis Pater
7accae4b6a Merge pull request #171 from cielhaidir/main
feat(copilot): Add copilot usage monitoring in endpoint /api-call
2026-02-04 01:02:57 +08:00
Luis Pater
3354fae391 Merge pull request #162 from taetaetae/fix/kiro-compaction-empty-content
fix(kiro): handle empty content in messages to prevent Bad Request errors
2026-02-04 01:01:48 +08:00
宝宝宝
4939865f6d Add a new client application - Lin Jun 2026-02-03 23:55:24 +08:00
宝宝宝
3da7f7482e Add a new client application - Lin Jun 2026-02-03 23:36:34 +08:00
宝宝宝
9072b029b2 Add a new client application - Lin Jun 2026-02-03 23:35:53 +08:00
宝宝宝
c296cfb8c0 docs: Add a new client application - Lin Jun 2026-02-03 23:32:50 +08:00
Luis Pater
2707377fcb docs: add AICodeMirror sponsorship details to README files 2026-02-03 22:34:50 +08:00
Luis Pater
259f586ff7 Fixed: #1398
fix(translator): use model group caching for client signature validation
2026-02-03 22:04:52 +08:00
Luis Pater
d885b81f23 Fixed: #1403
fix(translator): handle "input" field transformation for OpenAI responses
2026-02-03 21:49:30 +08:00
Luis Pater
fe6bffd080 fixed: #1407
fix(translator): adjust "developer" role to "user" and ignore unsupported tool types
2026-02-03 21:41:17 +08:00
starsdream666
1a81e8a98a 一致性问题修复 2026-02-03 21:11:20 +08:00
yuechenglong.5
0b889c6028 feat(registry): add kiro channel support for model definitions
Add kiro as a new supported channel in GetStaticModelDefinitionsByChannel
function, enabling retrieval of Kiro model definitions alongside existing
providers like qwen, iflow, and github-copilot.
2026-02-03 20:55:10 +08:00
starsdream666
f6bb0011f9 修复kiro模型列表缺失 2026-02-03 20:33:13 +08:00
Skyuno
fcdd91895e Merge remote-tracking branch 'upstream/main' into fix/json-truncation-rework 2026-02-03 20:28:32 +08:00
Skyuno
8dc4fc4ff5 fix(idc): prioritize email for filename to prevent collisions
- Use email as primary identifier for IDC tokens (unique, no sequence needed)
- Add sequence number only when email is unavailable
- Use startUrl identifier as secondary fallback with sequence
- Update GenerateTokenFileName in aws.go with consistent logic
2026-02-03 20:04:36 +08:00
starsdream666
9e9a860bda Merge branch 'router-for-me:main' into main 2026-02-03 16:50:42 +08:00
“cielhaidir”
6cd32028c3 refactor: clean up whitespace in enrichCopilotTokenResponse function 2026-02-03 13:14:21 +08:00
“cielhaidir”
ebd58ef33a feat(copilot): enhance quota response with reset dates for enterprise and non-enterprise accounts 2026-02-03 13:13:17 +08:00
“cielhaidir”
92791194e5 feat(copilot): add GitHub Copilot quota management endpoints and response enrichment 2026-02-03 13:02:51 +08:00
taetaetae
1f7c58f7ce refactor: use constants for default assistant messages
Apply code review feedback from gemini-code-assist:
- Define default messages as local constants to improve maintainability
- Avoid magic strings in the empty content handling logic
2026-02-03 07:10:38 +09:00
Luis Pater
b9cdc2f54c chore: remove .air.toml configuration file and update .gitignore 2026-02-03 01:52:35 +08:00
Luis Pater
5e23975d6e Merge branch 'router-for-me:main' into main 2026-02-03 01:50:45 +08:00
Luis Pater
420937c848 Merge pull request #166 from cielhaidir/main
feat: add .air.toml configuration file and update .gitignore for build artifacts
2026-02-03 01:46:02 +08:00
Luis Pater
e1a353ca20 Merge pull request #159 from Skyuno/fix/filter-web-search-tool
fix(kiro): filter web search tool
2026-02-03 01:45:23 +08:00
Luis Pater
250f212fa3 fix(executor): handle "global" location in AI platform URL generation 2026-02-03 01:39:57 +08:00
Cyrus
a275db3fdb fix(logging): expand tilde in auth-dir and log resolution errors
- Use util.ResolveAuthDir to properly expand ~ to user home directory
- Fixes issue where logs were created in literal "~/.cli-proxy-api" folder
- Add warning log when auth-dir resolution fails for debugging

Bug introduced in 62e2b67 (refactor(logging): centralize log directory
resolution logic), where strings.TrimSpace was used instead of
util.ResolveAuthDir to process auth-dir path.
2026-02-03 00:02:54 +08:00
“cielhaidir”
95a3e32a12 feat: add .air.toml configuration file and update .gitignore for build artifacts
fix: improve PatchOAuthModelAlias logic for handling channel aliases

feat: add support for GitHub Copilot in model definitions
2026-02-02 17:53:58 +08:00
sususu98
233be6272a fix(auth): 400 invalid_request_error 立即返回不再重试
当上游返回 400 Bad Request 且错误消息包含 invalid_request_error 时,
表示请求本身格式错误,切换账户不会改变结果。

修改:
- 添加 isRequestInvalidError 判定函数
- 内层循环遇到此错误立即返回,不遍历其他账户
- 外层循环不再对此类错误进行重试
2026-02-02 17:35:51 +08:00
chujian
47cb52385e sdk/cliproxy/auth: update selector tests 2026-02-02 05:26:04 +08:00
Skyuno
3c7a5afdcc feat: inject web_search alternative hint instead of silently filtering 2026-02-02 05:19:06 +08:00
Skyuno
5dc936a9a4 fix: filter out web_search/websearch tools unsupported by Kiro API 2026-02-02 05:19:06 +08:00
Skyuno
ba168ec003 fix(kiro): skip _partial field (may contain hallucinated paths), add pwd hint for retry 2026-02-02 05:17:39 +08:00
Skyuno
a12e22c66f Revert "Merge pull request #150 from PancakeZik/fix/write-tool-truncation-handling"
This reverts commit fd5b669c87, reversing
changes made to 30d832c9b1.
2026-02-02 05:17:39 +08:00
starsdream666
4c50a7281a Update docker-image.yml 2026-02-02 00:01:00 +08:00
starsdream666
80d3fa384e Update docker-image.yml 2026-02-01 23:58:06 +08:00
taetaetae
b45ede0b71 fix(kiro): handle empty content in messages to prevent Bad Request errors
Problem:
- OpenCode's /compaction command and auto-compaction (at 80%+ context)
  sends requests that can result in empty assistant message content
- Kiro API strictly requires non-empty content for all messages
- This causes 'Bad Request: Improperly formed request' errors
- After compaction failure, the malformed message stays in history,
  breaking all subsequent requests in the session

Solution:
- Add fallback content for empty assistant messages in
  buildAssistantMessageFromOpenAI()
- Add history truncation (max 50 messages) to prevent oversized requests
- This ensures all messages have valid content before sending to Kiro API

Fixes issues with:
- /compaction command returning Bad Request
- Auto-compaction breaking sessions
- Conversations becoming unresponsive after compaction failure
2026-02-01 15:47:18 +09:00
ThanhNguyxn
a406ca2d5a fix(store): add proper GC with Handler and interval gating
Address maintainer feedback on PR #1239:
- Add Handler: repo.DeleteObject to prevent nil panic in Prune
- Handle ErrLooseObjectsNotSupported gracefully
- Add 5-minute interval gating to avoid repack overhead on every write
- Remove sirupsen/logrus dependency (best-effort silent GC)

Fixes #1104
2026-02-01 11:19:43 +07:00
105 changed files with 4599 additions and 881 deletions

View File

@@ -1,13 +1,14 @@
name: docker-image
on:
workflow_dispatch:
push:
tags:
- v*
env:
APP_NAME: CLIProxyAPI
DOCKERHUB_REPO: eceasy/cli-proxy-api-plus
DOCKERHUB_REPO: ${{ secrets.DOCKERHUB_USERNAME }}/cli-proxy-api-plus
jobs:
docker_amd64:

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ logs/*
conv/*
temp/*
refs/*
tmp/*
# Storage backends
pgstore/*

BIN
assets/aicodemirror.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -77,6 +77,7 @@ func main() {
var noBrowser bool
var oauthCallbackPort int
var antigravityLogin bool
var kimiLogin bool
var kiroLogin bool
var kiroGoogleLogin bool
var kiroAWSLogin bool
@@ -102,6 +103,7 @@ func main() {
flag.BoolVar(&useIncognito, "incognito", false, "Open browser in incognito/private mode for OAuth (useful for multiple accounts)")
flag.BoolVar(&noIncognito, "no-incognito", false, "Force disable incognito mode (uses existing browser session)")
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth")
flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
@@ -501,6 +503,8 @@ func main() {
cmd.DoIFlowLogin(cfg, options)
} else if iflowCookie {
cmd.DoIFlowCookieAuth(cfg, options)
} else if kimiLogin {
cmd.DoKimiLogin(cfg, options)
} else if kiroLogin {
// For Kiro auth, default to incognito mode for multi-account support
// Users can explicitly override with --no-incognito

View File

@@ -40,6 +40,11 @@ api-keys:
# Enable debug logging
debug: false
# Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety.
pprof:
enable: false
addr: "127.0.0.1:8316"
# When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
commercial-mode: false
@@ -231,7 +236,7 @@ nonstream-keepalive-interval: 0
# Global OAuth model name aliases (per channel)
# These aliases rename model IDs for both model listing and request routing.
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot.
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, kimi.
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
# You can repeat the same name with different aliases to expose multiple client model names.
#oauth-model-alias:
@@ -275,6 +280,9 @@ nonstream-keepalive-interval: 0
# iflow:
# - name: "glm-4.7"
# alias: "glm-god"
# kimi:
# - name: "kimi-k2.5"
# alias: "k2.5"
# kiro:
# - name: "kiro-claude-opus-4-5"
# alias: "op45"
@@ -304,6 +312,8 @@ nonstream-keepalive-interval: 0
# - "vision-model"
# iflow:
# - "tstars2.0"
# kimi:
# - "kimi-k2-thinking"
# kiro:
# - "kiro-claude-haiku-4-5"
# github-copilot:

4
go.mod
View File

@@ -5,6 +5,7 @@ go 1.24.0
require (
github.com/andybalholm/brotli v1.0.6
github.com/fsnotify/fsnotify v1.9.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-gonic/gin v1.10.1
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
github.com/google/uuid v1.6.0
@@ -13,8 +14,8 @@ require (
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.17.4
github.com/minio/minio-go/v7 v7.0.66
github.com/refraction-networking/utls v1.8.2
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/refraction-networking/utls v1.8.2
github.com/sirupsen/logrus v1.9.3
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
@@ -41,7 +42,6 @@ require (
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect

View File

@@ -13,12 +13,13 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
"golang.org/x/net/proxy"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
const defaultAPICallTimeout = 60 * time.Second
@@ -55,6 +56,7 @@ type apiCallResponse struct {
StatusCode int `json:"status_code"`
Header map[string][]string `json:"header"`
Body string `json:"body"`
Quota *QuotaSnapshots `json:"quota,omitempty"`
}
// APICall makes a generic HTTP request on behalf of the management API caller.
@@ -97,6 +99,8 @@ type apiCallResponse struct {
// - status_code: Upstream HTTP status code.
// - header: Upstream response headers.
// - body: Upstream response body as string.
// - quota (optional): For GitHub Copilot enterprise accounts, contains quota_snapshots
// with details for chat, completions, and premium_interactions.
//
// Example:
//
@@ -236,6 +240,13 @@ func (h *Handler) APICall(c *gin.Context) {
Body: string(respBody),
}
// If this is a GitHub Copilot token endpoint response, try to enrich with quota information
if resp.StatusCode == http.StatusOK &&
strings.Contains(urlStr, "copilot_internal") &&
strings.Contains(urlStr, "/token") {
response = h.enrichCopilotTokenResponse(c.Request.Context(), response, auth, urlStr)
}
// Return response in the same format as the request
if isCBOR {
cborData, errMarshal := cbor.Marshal(response)
@@ -735,3 +746,344 @@ func buildProxyTransport(proxyStr string) *http.Transport {
log.Debugf("unsupported proxy scheme: %s", proxyURL.Scheme)
return nil
}
// QuotaDetail represents quota information for a specific resource type
type QuotaDetail struct {
Entitlement float64 `json:"entitlement"`
OverageCount float64 `json:"overage_count"`
OveragePermitted bool `json:"overage_permitted"`
PercentRemaining float64 `json:"percent_remaining"`
QuotaID string `json:"quota_id"`
QuotaRemaining float64 `json:"quota_remaining"`
Remaining float64 `json:"remaining"`
Unlimited bool `json:"unlimited"`
}
// QuotaSnapshots contains quota details for different resource types
type QuotaSnapshots struct {
Chat QuotaDetail `json:"chat"`
Completions QuotaDetail `json:"completions"`
PremiumInteractions QuotaDetail `json:"premium_interactions"`
}
// CopilotUsageResponse represents the GitHub Copilot usage information
type CopilotUsageResponse struct {
AccessTypeSKU string `json:"access_type_sku"`
AnalyticsTrackingID string `json:"analytics_tracking_id"`
AssignedDate string `json:"assigned_date"`
CanSignupForLimited bool `json:"can_signup_for_limited"`
ChatEnabled bool `json:"chat_enabled"`
CopilotPlan string `json:"copilot_plan"`
OrganizationLoginList []interface{} `json:"organization_login_list"`
OrganizationList []interface{} `json:"organization_list"`
QuotaResetDate string `json:"quota_reset_date"`
QuotaSnapshots QuotaSnapshots `json:"quota_snapshots"`
}
type copilotQuotaRequest struct {
AuthIndexSnake *string `json:"auth_index"`
AuthIndexCamel *string `json:"authIndex"`
AuthIndexPascal *string `json:"AuthIndex"`
}
// GetCopilotQuota fetches GitHub Copilot quota information from the /copilot_internal/user endpoint.
//
// Endpoint:
//
// GET /v0/management/copilot-quota
//
// Query Parameters (optional):
// - auth_index: The credential "auth_index" from GET /v0/management/auth-files.
// If omitted, uses the first available GitHub Copilot credential.
//
// Response:
//
// Returns the CopilotUsageResponse with quota_snapshots containing detailed quota information
// for chat, completions, and premium_interactions.
//
// Example:
//
// curl -sS -X GET "http://127.0.0.1:8317/v0/management/copilot-quota?auth_index=<AUTH_INDEX>" \
// -H "Authorization: Bearer <MANAGEMENT_KEY>"
func (h *Handler) GetCopilotQuota(c *gin.Context) {
authIndex := strings.TrimSpace(c.Query("auth_index"))
if authIndex == "" {
authIndex = strings.TrimSpace(c.Query("authIndex"))
}
if authIndex == "" {
authIndex = strings.TrimSpace(c.Query("AuthIndex"))
}
auth := h.findCopilotAuth(authIndex)
if auth == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no github copilot credential found"})
return
}
token, tokenErr := h.resolveTokenForAuth(c.Request.Context(), auth)
if tokenErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to refresh copilot token"})
return
}
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "copilot token not found"})
return
}
apiURL := "https://api.github.com/copilot_internal/user"
req, errNewRequest := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, apiURL, nil)
if errNewRequest != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build request"})
return
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "CLIProxyAPIPlus")
req.Header.Set("Accept", "application/json")
httpClient := &http.Client{
Timeout: defaultAPICallTimeout,
Transport: h.apiCallTransport(auth),
}
resp, errDo := httpClient.Do(req)
if errDo != nil {
log.WithError(errDo).Debug("copilot quota request failed")
c.JSON(http.StatusBadGateway, gin.H{"error": "request failed"})
return
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("response body close error: %v", errClose)
}
}()
respBody, errReadAll := io.ReadAll(resp.Body)
if errReadAll != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read response"})
return
}
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{
"error": "github api request failed",
"status_code": resp.StatusCode,
"body": string(respBody),
})
return
}
var usage CopilotUsageResponse
if errUnmarshal := json.Unmarshal(respBody, &usage); errUnmarshal != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse response"})
return
}
c.JSON(http.StatusOK, usage)
}
// findCopilotAuth locates a GitHub Copilot credential by auth_index or returns the first available one
func (h *Handler) findCopilotAuth(authIndex string) *coreauth.Auth {
if h == nil || h.authManager == nil {
return nil
}
auths := h.authManager.List()
var firstCopilot *coreauth.Auth
for _, auth := range auths {
if auth == nil {
continue
}
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if provider != "copilot" && provider != "github" && provider != "github-copilot" {
continue
}
if firstCopilot == nil {
firstCopilot = auth
}
if authIndex != "" {
auth.EnsureIndex()
if auth.Index == authIndex {
return auth
}
}
}
return firstCopilot
}
// enrichCopilotTokenResponse fetches quota information and adds it to the Copilot token response body
func (h *Handler) enrichCopilotTokenResponse(ctx context.Context, response apiCallResponse, auth *coreauth.Auth, originalURL string) apiCallResponse {
if auth == nil || response.Body == "" {
return response
}
// Parse the token response to check if it's enterprise (null limited_user_quotas)
var tokenResp map[string]interface{}
if err := json.Unmarshal([]byte(response.Body), &tokenResp); err != nil {
log.WithError(err).Debug("enrichCopilotTokenResponse: failed to parse copilot token response")
return response
}
// Get the GitHub token to call the copilot_internal/user endpoint
token, tokenErr := h.resolveTokenForAuth(ctx, auth)
if tokenErr != nil {
log.WithError(tokenErr).Debug("enrichCopilotTokenResponse: failed to resolve token")
return response
}
if token == "" {
return response
}
// Fetch quota information from /copilot_internal/user
// Derive the base URL from the original token request to support proxies and test servers
parsedURL, errParse := url.Parse(originalURL)
if errParse != nil {
log.WithError(errParse).Debug("enrichCopilotTokenResponse: failed to parse URL")
return response
}
quotaURL := fmt.Sprintf("%s://%s/copilot_internal/user", parsedURL.Scheme, parsedURL.Host)
req, errNewRequest := http.NewRequestWithContext(ctx, http.MethodGet, quotaURL, nil)
if errNewRequest != nil {
log.WithError(errNewRequest).Debug("enrichCopilotTokenResponse: failed to build request")
return response
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "CLIProxyAPIPlus")
req.Header.Set("Accept", "application/json")
httpClient := &http.Client{
Timeout: defaultAPICallTimeout,
Transport: h.apiCallTransport(auth),
}
quotaResp, errDo := httpClient.Do(req)
if errDo != nil {
log.WithError(errDo).Debug("enrichCopilotTokenResponse: quota fetch HTTP request failed")
return response
}
defer func() {
if errClose := quotaResp.Body.Close(); errClose != nil {
log.Errorf("quota response body close error: %v", errClose)
}
}()
if quotaResp.StatusCode != http.StatusOK {
return response
}
quotaBody, errReadAll := io.ReadAll(quotaResp.Body)
if errReadAll != nil {
log.WithError(errReadAll).Debug("enrichCopilotTokenResponse: failed to read response")
return response
}
// Parse the quota response
var quotaData CopilotUsageResponse
if err := json.Unmarshal(quotaBody, &quotaData); err != nil {
log.WithError(err).Debug("enrichCopilotTokenResponse: failed to parse response")
return response
}
// Check if this is an enterprise account by looking for quota_snapshots in the response
// Enterprise accounts have quota_snapshots, non-enterprise have limited_user_quotas
var quotaRaw map[string]interface{}
if err := json.Unmarshal(quotaBody, &quotaRaw); err == nil {
if _, hasQuotaSnapshots := quotaRaw["quota_snapshots"]; hasQuotaSnapshots {
// Enterprise account - has quota_snapshots
tokenResp["quota_snapshots"] = quotaData.QuotaSnapshots
tokenResp["access_type_sku"] = quotaData.AccessTypeSKU
tokenResp["copilot_plan"] = quotaData.CopilotPlan
// Add quota reset date for enterprise (quota_reset_date_utc)
if quotaResetDateUTC, ok := quotaRaw["quota_reset_date_utc"]; ok {
tokenResp["quota_reset_date"] = quotaResetDateUTC
} else if quotaData.QuotaResetDate != "" {
tokenResp["quota_reset_date"] = quotaData.QuotaResetDate
}
} else {
// Non-enterprise account - build quota from limited_user_quotas and monthly_quotas
var quotaSnapshots QuotaSnapshots
// Get monthly quotas (total entitlement) and limited_user_quotas (remaining)
monthlyQuotas, hasMonthly := quotaRaw["monthly_quotas"].(map[string]interface{})
limitedQuotas, hasLimited := quotaRaw["limited_user_quotas"].(map[string]interface{})
// Process chat quota
if hasMonthly && hasLimited {
if chatTotal, ok := monthlyQuotas["chat"].(float64); ok {
chatRemaining := chatTotal // default to full if no limited quota
if chatLimited, ok := limitedQuotas["chat"].(float64); ok {
chatRemaining = chatLimited
}
percentRemaining := 0.0
if chatTotal > 0 {
percentRemaining = (chatRemaining / chatTotal) * 100.0
}
quotaSnapshots.Chat = QuotaDetail{
Entitlement: chatTotal,
Remaining: chatRemaining,
QuotaRemaining: chatRemaining,
PercentRemaining: percentRemaining,
QuotaID: "chat",
Unlimited: false,
}
}
// Process completions quota
if completionsTotal, ok := monthlyQuotas["completions"].(float64); ok {
completionsRemaining := completionsTotal // default to full if no limited quota
if completionsLimited, ok := limitedQuotas["completions"].(float64); ok {
completionsRemaining = completionsLimited
}
percentRemaining := 0.0
if completionsTotal > 0 {
percentRemaining = (completionsRemaining / completionsTotal) * 100.0
}
quotaSnapshots.Completions = QuotaDetail{
Entitlement: completionsTotal,
Remaining: completionsRemaining,
QuotaRemaining: completionsRemaining,
PercentRemaining: percentRemaining,
QuotaID: "completions",
Unlimited: false,
}
}
}
// Premium interactions don't exist for non-enterprise, leave as zero values
quotaSnapshots.PremiumInteractions = QuotaDetail{
QuotaID: "premium_interactions",
Unlimited: false,
}
// Add quota_snapshots to the token response
tokenResp["quota_snapshots"] = quotaSnapshots
tokenResp["access_type_sku"] = quotaData.AccessTypeSKU
tokenResp["copilot_plan"] = quotaData.CopilotPlan
// Add quota reset date for non-enterprise (limited_user_reset_date)
if limitedResetDate, ok := quotaRaw["limited_user_reset_date"]; ok {
tokenResp["quota_reset_date"] = limitedResetDate
}
}
}
// Re-serialize the enriched response
enrichedBody, errMarshal := json.Marshal(tokenResp)
if errMarshal != nil {
log.WithError(errMarshal).Debug("failed to marshal enriched response")
return response
}
response.Body = string(enrichedBody)
return response
}

View File

@@ -29,6 +29,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
@@ -1613,6 +1614,82 @@ func (h *Handler) RequestQwenToken(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
func (h *Handler) RequestKimiToken(c *gin.Context) {
ctx := context.Background()
fmt.Println("Initializing Kimi authentication...")
state := fmt.Sprintf("kmi-%d", time.Now().UnixNano())
// Initialize Kimi auth service
kimiAuth := kimi.NewKimiAuth(h.cfg)
// Generate authorization URL
deviceFlow, errStartDeviceFlow := kimiAuth.StartDeviceFlow(ctx)
if errStartDeviceFlow != nil {
log.Errorf("Failed to generate authorization URL: %v", errStartDeviceFlow)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
return
}
authURL := deviceFlow.VerificationURIComplete
if authURL == "" {
authURL = deviceFlow.VerificationURI
}
RegisterOAuthSession(state, "kimi")
go func() {
fmt.Println("Waiting for authentication...")
authBundle, errWaitForAuthorization := kimiAuth.WaitForAuthorization(ctx, deviceFlow)
if errWaitForAuthorization != nil {
SetOAuthSessionError(state, "Authentication failed")
fmt.Printf("Authentication failed: %v\n", errWaitForAuthorization)
return
}
// Create token storage
tokenStorage := kimiAuth.CreateTokenStorage(authBundle)
metadata := map[string]any{
"type": "kimi",
"access_token": authBundle.TokenData.AccessToken,
"refresh_token": authBundle.TokenData.RefreshToken,
"token_type": authBundle.TokenData.TokenType,
"scope": authBundle.TokenData.Scope,
"timestamp": time.Now().UnixMilli(),
}
if authBundle.TokenData.ExpiresAt > 0 {
expired := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
metadata["expired"] = expired
}
if strings.TrimSpace(authBundle.DeviceID) != "" {
metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID)
}
fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli())
record := &coreauth.Auth{
ID: fileName,
Provider: "kimi",
FileName: fileName,
Label: "Kimi User",
Storage: tokenStorage,
Metadata: metadata,
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save authentication tokens: %v", errSave)
SetOAuthSessionError(state, "Failed to save authentication tokens")
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
fmt.Println("You can now use Kimi services through this CLI")
CompleteOAuthSession(state)
CompleteOAuthSessionsByProvider("kimi")
}()
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
func (h *Handler) RequestIFlowToken(c *gin.Context) {
ctx := context.Background()

View File

@@ -754,18 +754,22 @@ func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})
normalized := normalizedMap[channel]
if len(normalized) == 0 {
// Only delete if channel exists, otherwise just create empty entry
if h.cfg.OAuthModelAlias != nil {
if _, ok := h.cfg.OAuthModelAlias[channel]; ok {
delete(h.cfg.OAuthModelAlias, channel)
if len(h.cfg.OAuthModelAlias) == 0 {
h.cfg.OAuthModelAlias = nil
}
h.persist(c)
return
}
}
// Create new channel with empty aliases
if h.cfg.OAuthModelAlias == nil {
c.JSON(404, gin.H{"error": "channel not found"})
return
}
if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
c.JSON(404, gin.H{"error": "channel not found"})
return
}
delete(h.cfg.OAuthModelAlias, channel)
if len(h.cfg.OAuthModelAlias) == 0 {
h.cfg.OAuthModelAlias = nil
h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)
}
h.cfg.OAuthModelAlias[channel] = []config.OAuthModelAlias{}
h.persist(c)
return
}

View File

@@ -649,6 +649,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
@@ -979,10 +980,6 @@ func (s *Server) UpdateClients(cfg *config.Config) {
s.handlers.UpdateClients(&cfg.SDKConfig)
if !cfg.RemoteManagement.DisableControlPanel {
staticDir := managementasset.StaticDir(s.configFilePath)
go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository)
}
if s.mgmt != nil {
s.mgmt.SetConfig(cfg)
s.mgmt.SetAuthManager(s.handlers.AuthManager)

396
internal/auth/kimi/kimi.go Normal file
View File

@@ -0,0 +1,396 @@
// Package kimi provides authentication and token management for Kimi (Moonshot AI) API.
// It handles the RFC 8628 OAuth2 Device Authorization Grant flow for secure authentication.
package kimi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"runtime"
"strings"
"time"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
log "github.com/sirupsen/logrus"
)
const (
// kimiClientID is Kimi Code's OAuth client ID.
kimiClientID = "17e5f671-d194-4dfb-9706-5516cb48c098"
// kimiOAuthHost is the OAuth server endpoint.
kimiOAuthHost = "https://auth.kimi.com"
// kimiDeviceCodeURL is the endpoint for requesting device codes.
kimiDeviceCodeURL = kimiOAuthHost + "/api/oauth/device_authorization"
// kimiTokenURL is the endpoint for exchanging device codes for tokens.
kimiTokenURL = kimiOAuthHost + "/api/oauth/token"
// KimiAPIBaseURL is the base URL for Kimi API requests.
KimiAPIBaseURL = "https://api.kimi.com/coding"
// defaultPollInterval is the default interval for polling token endpoint.
defaultPollInterval = 5 * time.Second
// maxPollDuration is the maximum time to wait for user authorization.
maxPollDuration = 15 * time.Minute
// refreshThresholdSeconds is when to refresh token before expiry (5 minutes).
refreshThresholdSeconds = 300
)
// KimiAuth handles Kimi authentication flow.
type KimiAuth struct {
deviceClient *DeviceFlowClient
cfg *config.Config
}
// NewKimiAuth creates a new KimiAuth service instance.
func NewKimiAuth(cfg *config.Config) *KimiAuth {
return &KimiAuth{
deviceClient: NewDeviceFlowClient(cfg),
cfg: cfg,
}
}
// StartDeviceFlow initiates the device flow authentication.
func (k *KimiAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
return k.deviceClient.RequestDeviceCode(ctx)
}
// WaitForAuthorization polls for user authorization and returns the auth bundle.
func (k *KimiAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*KimiAuthBundle, error) {
tokenData, err := k.deviceClient.PollForToken(ctx, deviceCode)
if err != nil {
return nil, err
}
return &KimiAuthBundle{
TokenData: tokenData,
DeviceID: k.deviceClient.deviceID,
}, nil
}
// CreateTokenStorage creates a new KimiTokenStorage from auth bundle.
func (k *KimiAuth) CreateTokenStorage(bundle *KimiAuthBundle) *KimiTokenStorage {
expired := ""
if bundle.TokenData.ExpiresAt > 0 {
expired = time.Unix(bundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
}
return &KimiTokenStorage{
AccessToken: bundle.TokenData.AccessToken,
RefreshToken: bundle.TokenData.RefreshToken,
TokenType: bundle.TokenData.TokenType,
Scope: bundle.TokenData.Scope,
DeviceID: strings.TrimSpace(bundle.DeviceID),
Expired: expired,
Type: "kimi",
}
}
// DeviceFlowClient handles the OAuth2 device flow for Kimi.
type DeviceFlowClient struct {
httpClient *http.Client
cfg *config.Config
deviceID string
}
// NewDeviceFlowClient creates a new device flow client.
func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient {
return NewDeviceFlowClientWithDeviceID(cfg, "")
}
// NewDeviceFlowClientWithDeviceID creates a new device flow client with the specified device ID.
func NewDeviceFlowClientWithDeviceID(cfg *config.Config, deviceID string) *DeviceFlowClient {
client := &http.Client{Timeout: 30 * time.Second}
if cfg != nil {
client = util.SetProxy(&cfg.SDKConfig, client)
}
resolvedDeviceID := strings.TrimSpace(deviceID)
if resolvedDeviceID == "" {
resolvedDeviceID = getOrCreateDeviceID()
}
return &DeviceFlowClient{
httpClient: client,
cfg: cfg,
deviceID: resolvedDeviceID,
}
}
// getOrCreateDeviceID returns an in-memory device ID for the current authentication flow.
func getOrCreateDeviceID() string {
return uuid.New().String()
}
// getDeviceModel returns a device model string.
func getDeviceModel() string {
osName := runtime.GOOS
arch := runtime.GOARCH
switch osName {
case "darwin":
return fmt.Sprintf("macOS %s", arch)
case "windows":
return fmt.Sprintf("Windows %s", arch)
case "linux":
return fmt.Sprintf("Linux %s", arch)
default:
return fmt.Sprintf("%s %s", osName, arch)
}
}
// getHostname returns the machine hostname.
func getHostname() string {
hostname, err := os.Hostname()
if err != nil {
return "unknown"
}
return hostname
}
// commonHeaders returns headers required for Kimi API requests.
func (c *DeviceFlowClient) commonHeaders() map[string]string {
return map[string]string{
"X-Msh-Platform": "cli-proxy-api",
"X-Msh-Version": "1.0.0",
"X-Msh-Device-Name": getHostname(),
"X-Msh-Device-Model": getDeviceModel(),
"X-Msh-Device-Id": c.deviceID,
}
}
// RequestDeviceCode initiates the device flow by requesting a device code from Kimi.
func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) {
data := url.Values{}
data.Set("client_id", kimiClientID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiDeviceCodeURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("kimi: failed to create device code request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
for k, v := range c.commonHeaders() {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("kimi: device code request failed: %w", err)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("kimi device code: close body error: %v", errClose)
}
}()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("kimi: failed to read device code response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("kimi: device code request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var deviceCode DeviceCodeResponse
if err = json.Unmarshal(bodyBytes, &deviceCode); err != nil {
return nil, fmt.Errorf("kimi: failed to parse device code response: %w", err)
}
return &deviceCode, nil
}
// PollForToken polls the token endpoint until the user authorizes or the device code expires.
func (c *DeviceFlowClient) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*KimiTokenData, error) {
if deviceCode == nil {
return nil, fmt.Errorf("kimi: device code is nil")
}
interval := time.Duration(deviceCode.Interval) * time.Second
if interval < defaultPollInterval {
interval = defaultPollInterval
}
deadline := time.Now().Add(maxPollDuration)
if deviceCode.ExpiresIn > 0 {
codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
if codeDeadline.Before(deadline) {
deadline = codeDeadline
}
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("kimi: context cancelled: %w", ctx.Err())
case <-ticker.C:
if time.Now().After(deadline) {
return nil, fmt.Errorf("kimi: device code expired")
}
token, pollErr, shouldContinue := c.exchangeDeviceCode(ctx, deviceCode.DeviceCode)
if token != nil {
return token, nil
}
if !shouldContinue {
return nil, pollErr
}
// Continue polling
}
}
}
// exchangeDeviceCode attempts to exchange the device code for an access token.
// Returns (token, error, shouldContinue).
func (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode string) (*KimiTokenData, error, bool) {
data := url.Values{}
data.Set("client_id", kimiClientID)
data.Set("device_code", deviceCode)
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("kimi: failed to create token request: %w", err), false
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
for k, v := range c.commonHeaders() {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("kimi: token request failed: %w", err), false
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("kimi token exchange: close body error: %v", errClose)
}
}()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("kimi: failed to read token response: %w", err), false
}
// Parse response - Kimi returns 200 for both success and pending states
var oauthResp struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn float64 `json:"expires_in"`
Scope string `json:"scope"`
}
if err = json.Unmarshal(bodyBytes, &oauthResp); err != nil {
return nil, fmt.Errorf("kimi: failed to parse token response: %w", err), false
}
if oauthResp.Error != "" {
switch oauthResp.Error {
case "authorization_pending":
return nil, nil, true // Continue polling
case "slow_down":
return nil, nil, true // Continue polling (with increased interval handled by caller)
case "expired_token":
return nil, fmt.Errorf("kimi: device code expired"), false
case "access_denied":
return nil, fmt.Errorf("kimi: access denied by user"), false
default:
return nil, fmt.Errorf("kimi: OAuth error: %s - %s", oauthResp.Error, oauthResp.ErrorDescription), false
}
}
if oauthResp.AccessToken == "" {
return nil, fmt.Errorf("kimi: empty access token in response"), false
}
var expiresAt int64
if oauthResp.ExpiresIn > 0 {
expiresAt = time.Now().Unix() + int64(oauthResp.ExpiresIn)
}
return &KimiTokenData{
AccessToken: oauthResp.AccessToken,
RefreshToken: oauthResp.RefreshToken,
TokenType: oauthResp.TokenType,
ExpiresAt: expiresAt,
Scope: oauthResp.Scope,
}, nil, false
}
// RefreshToken exchanges a refresh token for a new access token.
func (c *DeviceFlowClient) RefreshToken(ctx context.Context, refreshToken string) (*KimiTokenData, error) {
data := url.Values{}
data.Set("client_id", kimiClientID)
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("kimi: failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
for k, v := range c.commonHeaders() {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("kimi: refresh request failed: %w", err)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("kimi refresh token: close body error: %v", errClose)
}
}()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("kimi: failed to read refresh response: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("kimi: refresh token rejected (status %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("kimi: refresh failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn float64 `json:"expires_in"`
Scope string `json:"scope"`
}
if err = json.Unmarshal(bodyBytes, &tokenResp); err != nil {
return nil, fmt.Errorf("kimi: failed to parse refresh response: %w", err)
}
if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("kimi: empty access token in refresh response")
}
var expiresAt int64
if tokenResp.ExpiresIn > 0 {
expiresAt = time.Now().Unix() + int64(tokenResp.ExpiresIn)
}
return &KimiTokenData{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
TokenType: tokenResp.TokenType,
ExpiresAt: expiresAt,
Scope: tokenResp.Scope,
}, nil
}

116
internal/auth/kimi/token.go Normal file
View File

@@ -0,0 +1,116 @@
// Package kimi provides authentication and token management functionality
// for Kimi (Moonshot AI) services. It handles OAuth2 device flow token storage,
// serialization, and retrieval for maintaining authenticated sessions with the Kimi API.
package kimi
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
)
// KimiTokenStorage stores OAuth2 token information for Kimi API authentication.
type KimiTokenStorage struct {
// AccessToken is the OAuth2 access token used for authenticating API requests.
AccessToken string `json:"access_token"`
// RefreshToken is the OAuth2 refresh token used to obtain new access tokens.
RefreshToken string `json:"refresh_token"`
// TokenType is the type of token, typically "Bearer".
TokenType string `json:"token_type"`
// Scope is the OAuth2 scope granted to the token.
Scope string `json:"scope,omitempty"`
// DeviceID is the OAuth device flow identifier used for Kimi requests.
DeviceID string `json:"device_id,omitempty"`
// Expired is the RFC3339 timestamp when the access token expires.
Expired string `json:"expired,omitempty"`
// Type indicates the authentication provider type, always "kimi" for this storage.
Type string `json:"type"`
}
// KimiTokenData holds the raw OAuth token response from Kimi.
type KimiTokenData struct {
// AccessToken is the OAuth2 access token.
AccessToken string `json:"access_token"`
// RefreshToken is the OAuth2 refresh token.
RefreshToken string `json:"refresh_token"`
// TokenType is the type of token, typically "Bearer".
TokenType string `json:"token_type"`
// ExpiresAt is the Unix timestamp when the token expires.
ExpiresAt int64 `json:"expires_at"`
// Scope is the OAuth2 scope granted to the token.
Scope string `json:"scope"`
}
// KimiAuthBundle bundles authentication data for storage.
type KimiAuthBundle struct {
// TokenData contains the OAuth token information.
TokenData *KimiTokenData
// DeviceID is the device identifier used during OAuth device flow.
DeviceID string
}
// DeviceCodeResponse represents Kimi's device code response.
type DeviceCodeResponse struct {
// DeviceCode is the device verification code.
DeviceCode string `json:"device_code"`
// UserCode is the code the user must enter at the verification URI.
UserCode string `json:"user_code"`
// VerificationURI is the URL where the user should enter the code.
VerificationURI string `json:"verification_uri,omitempty"`
// VerificationURIComplete is the URL with the code pre-filled.
VerificationURIComplete string `json:"verification_uri_complete"`
// ExpiresIn is the number of seconds until the device code expires.
ExpiresIn int `json:"expires_in"`
// Interval is the minimum number of seconds to wait between polling requests.
Interval int `json:"interval"`
}
// SaveTokenToFile serializes the Kimi token storage to a JSON file.
func (ts *KimiTokenStorage) SaveTokenToFile(authFilePath string) error {
misc.LogSavingCredentials(authFilePath)
ts.Type = "kimi"
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
f, err := os.Create(authFilePath)
if err != nil {
return fmt.Errorf("failed to create token file: %w", err)
}
defer func() {
_ = f.Close()
}()
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
if err = encoder.Encode(ts); err != nil {
return fmt.Errorf("failed to write token to file: %w", err)
}
return nil
}
// IsExpired checks if the token has expired.
func (ts *KimiTokenStorage) IsExpired() bool {
if ts.Expired == "" {
return false // No expiry set, assume valid
}
t, err := time.Parse(time.RFC3339, ts.Expired)
if err != nil {
return true // Has expiry string but can't parse
}
// Consider expired if within refresh threshold
return time.Now().Add(time.Duration(refreshThresholdSeconds) * time.Second).After(t)
}
// NeedsRefresh checks if the token should be refreshed.
func (ts *KimiTokenStorage) NeedsRefresh() bool {
if ts.RefreshToken == "" {
return false // Can't refresh without refresh token
}
return ts.IsExpired()
}

View File

@@ -92,7 +92,7 @@ const KiroIDETokenFile = ".aws/sso/cache/kiro-auth-token.json"
// Default retry configuration for file reading
const (
defaultTokenReadMaxAttempts = 10 // Maximum retry attempts
defaultTokenReadMaxAttempts = 10 // Maximum retry attempts
defaultTokenReadBaseDelay = 50 * time.Millisecond // Base delay between retries
)
@@ -301,7 +301,7 @@ func ListKiroTokenFiles() ([]string, error) {
}
cacheDir := filepath.Join(homeDir, ".aws", "sso", "cache")
// Check if directory exists
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
return nil, nil // No token files
@@ -488,14 +488,16 @@ func ExtractIDCIdentifier(startURL string) string {
// GenerateTokenFileName generates a unique filename for token storage.
// Priority: email > startUrl identifier (for IDC) > authMethod only
// Format: kiro-{authMethod}-{identifier}.json
// Email is unique, so no sequence suffix needed. Sequence is only added
// when email is unavailable to prevent filename collisions.
// Format: kiro-{authMethod}-{identifier}[-{seq}].json
func GenerateTokenFileName(tokenData *KiroTokenData) string {
authMethod := tokenData.AuthMethod
if authMethod == "" {
authMethod = "unknown"
}
// Priority 1: Use email if available
// Priority 1: Use email if available (no sequence needed, email is unique)
if tokenData.Email != "" {
// Sanitize email for filename (replace @ and . with -)
sanitizedEmail := tokenData.Email
@@ -504,14 +506,17 @@ func GenerateTokenFileName(tokenData *KiroTokenData) string {
return fmt.Sprintf("kiro-%s-%s.json", authMethod, sanitizedEmail)
}
// Priority 2: For IDC, use startUrl identifier
// Generate sequence only when email is unavailable
seq := time.Now().UnixNano() % 100000
// Priority 2: For IDC, use startUrl identifier with sequence
if authMethod == "idc" && tokenData.StartURL != "" {
identifier := ExtractIDCIdentifier(tokenData.StartURL)
if identifier != "" {
return fmt.Sprintf("kiro-%s-%s.json", authMethod, identifier)
return fmt.Sprintf("kiro-%s-%s-%05d.json", authMethod, identifier, seq)
}
}
// Priority 3: Fallback to authMethod only
return fmt.Sprintf("kiro-%s.json", authMethod)
// Priority 3: Fallback to authMethod only with sequence
return fmt.Sprintf("kiro-%s-%05d.json", authMethod, seq)
}

View File

@@ -238,7 +238,7 @@ func (k *KiroAuth) ListAvailableModels(ctx context.Context, tokenData *KiroToken
Description string `json:"description"`
RateMultiplier float64 `json:"rateMultiplier"`
RateUnit string `json:"rateUnit"`
TokenLimits struct {
TokenLimits *struct {
MaxInputTokens int `json:"maxInputTokens"`
} `json:"tokenLimits"`
} `json:"models"`
@@ -250,13 +250,17 @@ func (k *KiroAuth) ListAvailableModels(ctx context.Context, tokenData *KiroToken
models := make([]*KiroModel, 0, len(result.Models))
for _, m := range result.Models {
maxInputTokens := 0
if m.TokenLimits != nil {
maxInputTokens = m.TokenLimits.MaxInputTokens
}
models = append(models, &KiroModel{
ModelID: m.ModelID,
ModelName: m.ModelName,
Description: m.Description,
RateMultiplier: m.RateMultiplier,
RateUnit: m.RateUnit,
MaxInputTokens: m.TokenLimits.MaxInputTokens,
MaxInputTokens: maxInputTokens,
})
}

View File

@@ -19,6 +19,7 @@ func newAuthManager() *sdkAuth.Manager {
sdkAuth.NewQwenAuthenticator(),
sdkAuth.NewIFlowAuthenticator(),
sdkAuth.NewAntigravityAuthenticator(),
sdkAuth.NewKimiAuthenticator(),
sdkAuth.NewKiroAuthenticator(),
sdkAuth.NewGitHubCopilotAuthenticator(),
)

View File

@@ -0,0 +1,44 @@
package cmd
import (
"context"
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
log "github.com/sirupsen/logrus"
)
// DoKimiLogin triggers the OAuth device flow for Kimi (Moonshot AI) and saves tokens.
// It initiates the device flow authentication, displays the verification URL for the user,
// and waits for authorization before saving the tokens.
//
// Parameters:
// - cfg: The application configuration containing proxy and auth directory settings
// - options: Login options including browser behavior settings
func DoKimiLogin(cfg *config.Config, options *LoginOptions) {
if options == nil {
options = &LoginOptions{}
}
manager := newAuthManager()
authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser,
Metadata: map[string]string{},
Prompt: options.Prompt,
}
record, savedPath, err := manager.Login(context.Background(), "kimi", cfg, authOpts)
if err != nil {
log.Errorf("Kimi authentication failed: %v", err)
return
}
if savedPath != "" {
fmt.Printf("Authentication saved to %s\n", savedPath)
}
if record != nil && record.Label != "" {
fmt.Printf("Authenticated as %s\n", record.Label)
}
fmt.Println("Kimi authentication successful!")
}

View File

@@ -18,7 +18,10 @@ import (
"gopkg.in/yaml.v3"
)
const DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
const (
DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
DefaultPprofAddr = "127.0.0.1:8316"
)
// Config represents the application's configuration, loaded from a YAML file.
type Config struct {
@@ -41,6 +44,9 @@ type Config struct {
// Debug enables or disables debug-level logging and other debug features.
Debug bool `yaml:"debug" json:"debug"`
// Pprof config controls the optional pprof HTTP debug server.
Pprof PprofConfig `yaml:"pprof" json:"pprof"`
// CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage.
CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"`
@@ -134,6 +140,14 @@ type TLSConfig struct {
Key string `yaml:"key" json:"key"`
}
// PprofConfig holds pprof HTTP server settings.
type PprofConfig struct {
// Enable toggles the pprof HTTP debug server.
Enable bool `yaml:"enable" json:"enable"`
// Addr is the host:port address for the pprof HTTP server.
Addr string `yaml:"addr" json:"addr"`
}
// RemoteManagement holds management API configuration under 'remote-management'.
type RemoteManagement struct {
// AllowRemote toggles remote (non-localhost) access to management API.
@@ -521,14 +535,15 @@ func LoadConfig(configFile string) (*Config, error) {
// If optional is true and the file is missing, it returns an empty Config.
// If optional is true and the file is empty or invalid, it returns an empty Config.
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Perform oauth-model-alias migration before loading config.
// This migrates oauth-model-mappings to oauth-model-alias if needed.
if migrated, err := MigrateOAuthModelAlias(configFile); err != nil {
// Log warning but don't fail - config loading should still work
fmt.Printf("Warning: oauth-model-alias migration failed: %v\n", err)
} else if migrated {
fmt.Println("Migrated oauth-model-mappings to oauth-model-alias")
}
// NOTE: Startup oauth-model-alias migration is intentionally disabled.
// Reason: avoid mutating config.yaml during server startup.
// Re-enable the block below if automatic startup migration is needed again.
// if migrated, err := MigrateOAuthModelAlias(configFile); err != nil {
// // Log warning but don't fail - config loading should still work
// fmt.Printf("Warning: oauth-model-alias migration failed: %v\n", err)
// } else if migrated {
// fmt.Println("Migrated oauth-model-mappings to oauth-model-alias")
// }
// Read the entire configuration file into memory.
data, err := os.ReadFile(configFile)
@@ -556,6 +571,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.ErrorLogsMaxFiles = 10
cfg.UsageStatisticsEnabled = false
cfg.DisableCooling = false
cfg.Pprof.Enable = false
cfg.Pprof.Addr = DefaultPprofAddr
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
cfg.IncognitoBrowser = false // Default to normal browser (AWS uses incognito by force)
@@ -567,18 +584,21 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
var legacy legacyConfigData
if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil {
if cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) {
cfg.legacyMigrationPending = true
}
if cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) {
cfg.legacyMigrationPending = true
}
if cfg.migrateLegacyAmpConfig(&legacy) {
cfg.legacyMigrationPending = true
}
}
// NOTE: Startup legacy key migration is intentionally disabled.
// Reason: avoid mutating config.yaml during server startup.
// Re-enable the block below if automatic startup migration is needed again.
// var legacy legacyConfigData
// if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil {
// if cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) {
// cfg.legacyMigrationPending = true
// }
// if cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) {
// cfg.legacyMigrationPending = true
// }
// if cfg.migrateLegacyAmpConfig(&legacy) {
// cfg.legacyMigrationPending = true
// }
// }
// Hash remote management key if plaintext is detected (nested)
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
@@ -599,6 +619,11 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
}
cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr)
if cfg.Pprof.Addr == "" {
cfg.Pprof.Addr = DefaultPprofAddr
}
if cfg.LogsMaxTotalSizeMB < 0 {
cfg.LogsMaxTotalSizeMB = 0
}
@@ -637,17 +662,20 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Validate raw payload rules and drop invalid entries.
cfg.SanitizePayloadRules()
if cfg.legacyMigrationPending {
fmt.Println("Detected legacy configuration keys, attempting to persist the normalized config...")
if !optional && configFile != "" {
if err := SaveConfigPreserveComments(configFile, &cfg); err != nil {
return nil, fmt.Errorf("failed to persist migrated legacy config: %w", err)
}
fmt.Println("Legacy configuration normalized and persisted.")
} else {
fmt.Println("Legacy configuration normalized in memory; persistence skipped.")
}
}
// NOTE: Legacy migration persistence is intentionally disabled together with
// startup legacy migration to keep startup read-only for config.yaml.
// Re-enable the block below if automatic startup migration is needed again.
// if cfg.legacyMigrationPending {
// fmt.Println("Detected legacy configuration keys, attempting to persist the normalized config...")
// if !optional && configFile != "" {
// if err := SaveConfigPreserveComments(configFile, &cfg); err != nil {
// return nil, fmt.Errorf("failed to persist migrated legacy config: %w", err)
// }
// fmt.Println("Legacy configuration normalized and persisted.")
// } else {
// fmt.Println("Legacy configuration normalized in memory; persistence skipped.")
// }
// }
// Return the populated configuration struct.
return &cfg, nil
@@ -1133,8 +1161,13 @@ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
// key order and comments of existing keys in dst. New keys are only added if their
// value is non-zero to avoid polluting the config with defaults.
func mergeMappingPreserve(dst, src *yaml.Node) {
// value is non-zero and not a known default to avoid polluting the config with defaults.
func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) {
var currentPath []string
if len(path) > 0 {
currentPath = path[0]
}
if dst == nil || src == nil {
return
}
@@ -1148,16 +1181,19 @@ func mergeMappingPreserve(dst, src *yaml.Node) {
sk := src.Content[i]
sv := src.Content[i+1]
idx := findMapKeyIndex(dst, sk.Value)
childPath := appendPath(currentPath, sk.Value)
if idx >= 0 {
// Merge into existing value node (always update, even to zero values)
dv := dst.Content[idx+1]
mergeNodePreserve(dv, sv)
mergeNodePreserve(dv, sv, childPath)
} else {
// New key: only add if value is non-zero to avoid polluting config with defaults
if isZeroValueNode(sv) {
// New key: only add if value is non-zero and not a known default
candidate := deepCopyNode(sv)
pruneKnownDefaultsInNewNode(childPath, candidate)
if isKnownDefaultValue(childPath, candidate) {
continue
}
dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
dst.Content = append(dst.Content, deepCopyNode(sk), candidate)
}
}
}
@@ -1165,7 +1201,12 @@ func mergeMappingPreserve(dst, src *yaml.Node) {
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
// reusing destination nodes to keep comments and anchors. For sequences, it updates
// in-place by index.
func mergeNodePreserve(dst, src *yaml.Node) {
func mergeNodePreserve(dst, src *yaml.Node, path ...[]string) {
var currentPath []string
if len(path) > 0 {
currentPath = path[0]
}
if dst == nil || src == nil {
return
}
@@ -1174,7 +1215,7 @@ func mergeNodePreserve(dst, src *yaml.Node) {
if dst.Kind != yaml.MappingNode {
copyNodeShallow(dst, src)
}
mergeMappingPreserve(dst, src)
mergeMappingPreserve(dst, src, currentPath)
case yaml.SequenceNode:
// Preserve explicit null style if dst was null and src is empty sequence
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
@@ -1197,7 +1238,7 @@ func mergeNodePreserve(dst, src *yaml.Node) {
dst.Content[i] = deepCopyNode(src.Content[i])
continue
}
mergeNodePreserve(dst.Content[i], src.Content[i])
mergeNodePreserve(dst.Content[i], src.Content[i], currentPath)
if dst.Content[i] != nil && src.Content[i] != nil &&
dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
pruneMissingMapKeys(dst.Content[i], src.Content[i])
@@ -1239,6 +1280,94 @@ func findMapKeyIndex(mapNode *yaml.Node, key string) int {
return -1
}
// appendPath appends a key to the path, returning a new slice to avoid modifying the original.
func appendPath(path []string, key string) []string {
if len(path) == 0 {
return []string{key}
}
newPath := make([]string, len(path)+1)
copy(newPath, path)
newPath[len(path)] = key
return newPath
}
// 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.
func isKnownDefaultValue(path []string, node *yaml.Node) bool {
// First check if it's a zero value
if isZeroValueNode(node) {
return true
}
// Match known non-zero defaults by exact dotted path.
if len(path) == 0 {
return false
}
fullPath := strings.Join(path, ".")
// Check string defaults
if node.Kind == yaml.ScalarNode && node.Tag == "!!str" {
switch fullPath {
case "pprof.addr":
return node.Value == DefaultPprofAddr
case "remote-management.panel-github-repository":
return node.Value == DefaultPanelGitHubRepository
case "routing.strategy":
return node.Value == "round-robin"
}
}
// Check integer defaults
if node.Kind == yaml.ScalarNode && node.Tag == "!!int" {
switch fullPath {
case "error-logs-max-files":
return node.Value == "10"
}
}
return false
}
// pruneKnownDefaultsInNewNode removes default-valued descendants from a new node
// before it is appended into the destination YAML tree.
func pruneKnownDefaultsInNewNode(path []string, node *yaml.Node) {
if node == nil {
return
}
switch node.Kind {
case yaml.MappingNode:
filtered := make([]*yaml.Node, 0, len(node.Content))
for i := 0; i+1 < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
if keyNode == nil || valueNode == nil {
continue
}
childPath := appendPath(path, keyNode.Value)
if isKnownDefaultValue(childPath, valueNode) {
continue
}
pruneKnownDefaultsInNewNode(childPath, valueNode)
if (valueNode.Kind == yaml.MappingNode || valueNode.Kind == yaml.SequenceNode) &&
len(valueNode.Content) == 0 {
continue
}
filtered = append(filtered, keyNode, valueNode)
}
node.Content = filtered
case yaml.SequenceNode:
for _, child := range node.Content {
pruneKnownDefaultsInNewNode(path, child)
}
}
}
// isZeroValueNode returns true if the YAML node represents a zero/default value
// that should not be written as a new key to preserve config cleanliness.
// For mappings and sequences, recursively checks if all children are zero values.

View File

@@ -17,6 +17,7 @@ var antigravityModelConversionTable = map[string]string{
"gemini-claude-sonnet-4-5": "claude-sonnet-4-5",
"gemini-claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
"gemini-claude-opus-4-5-thinking": "claude-opus-4-5-thinking",
"gemini-claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
}
// defaultAntigravityAliases returns the default oauth-model-alias configuration
@@ -30,6 +31,7 @@ func defaultAntigravityAliases() []OAuthModelAlias {
{Name: "claude-sonnet-4-5", Alias: "gemini-claude-sonnet-4-5"},
{Name: "claude-sonnet-4-5-thinking", Alias: "gemini-claude-sonnet-4-5-thinking"},
{Name: "claude-opus-4-5-thinking", Alias: "gemini-claude-opus-4-5-thinking"},
{Name: "claude-opus-4-6-thinking", Alias: "gemini-claude-opus-4-6-thinking"},
}
}

View File

@@ -131,6 +131,9 @@ func TestMigrateOAuthModelAlias_ConvertsAntigravityModels(t *testing.T) {
if !strings.Contains(content, "claude-opus-4-5-thinking") {
t.Fatal("expected missing default alias claude-opus-4-5-thinking to be added")
}
if !strings.Contains(content, "claude-opus-4-6-thinking") {
t.Fatal("expected missing default alias claude-opus-4-6-thinking to be added")
}
}
func TestMigrateOAuthModelAlias_AddsDefaultIfNeitherExists(t *testing.T) {

View File

@@ -132,7 +132,10 @@ func ResolveLogDirectory(cfg *config.Config) string {
return logDir
}
if !isDirWritable(logDir) {
authDir := strings.TrimSpace(cfg.AuthDir)
authDir, err := util.ResolveAuthDir(cfg.AuthDir)
if err != nil {
log.Warnf("Failed to resolve auth-dir %q for log directory: %v", cfg.AuthDir, err)
}
if authDir != "" {
logDir = filepath.Join(authDir, "logs")
}

View File

@@ -28,6 +28,7 @@ const (
defaultManagementFallbackURL = "https://cpamc.router-for.me/"
managementAssetName = "management.html"
httpUserAgent = "CLIProxyAPI-management-updater"
managementSyncMinInterval = 30 * time.Second
updateCheckInterval = 3 * time.Hour
)
@@ -37,9 +38,7 @@ const ManagementFileName = managementAssetName
var (
lastUpdateCheckMu sync.Mutex
lastUpdateCheckTime time.Time
currentConfigPtr atomic.Pointer[config.Config]
disableControlPanel atomic.Bool
schedulerOnce sync.Once
schedulerConfigPath atomic.Value
)
@@ -50,16 +49,7 @@ func SetCurrentConfig(cfg *config.Config) {
currentConfigPtr.Store(nil)
return
}
prevDisabled := disableControlPanel.Load()
currentConfigPtr.Store(cfg)
disableControlPanel.Store(cfg.RemoteManagement.DisableControlPanel)
if prevDisabled && !cfg.RemoteManagement.DisableControlPanel {
lastUpdateCheckMu.Lock()
lastUpdateCheckTime = time.Time{}
lastUpdateCheckMu.Unlock()
}
}
// StartAutoUpdater launches a background goroutine that periodically ensures the management asset is up to date.
@@ -92,7 +82,7 @@ func runAutoUpdater(ctx context.Context) {
log.Debug("management asset auto-updater skipped: config not yet available")
return
}
if disableControlPanel.Load() {
if cfg.RemoteManagement.DisableControlPanel {
log.Debug("management asset auto-updater skipped: control panel disabled")
return
}
@@ -182,23 +172,32 @@ func FilePath(configFilePath string) string {
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
// The function is designed to run in a background goroutine and will never panic.
// It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes.
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) {
if ctx == nil {
ctx = context.Background()
}
if disableControlPanel.Load() {
log.Debug("management asset sync skipped: control panel disabled by configuration")
return
}
staticDir = strings.TrimSpace(staticDir)
if staticDir == "" {
log.Debug("management asset sync skipped: empty static directory")
return
}
lastUpdateCheckMu.Lock()
now := time.Now()
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval {
lastUpdateCheckMu.Unlock()
log.Debugf(
"management asset sync skipped by throttle: last attempt %v ago (interval %v)",
timeSinceLastAttempt.Round(time.Second),
managementSyncMinInterval,
)
return
}
lastUpdateCheckTime = now
lastUpdateCheckMu.Unlock()
localPath := filepath.Join(staticDir, managementAssetName)
localFileMissing := false
if _, errStat := os.Stat(localPath); errStat != nil {
@@ -209,18 +208,6 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL
}
}
// Rate limiting: check only once every 3 hours
lastUpdateCheckMu.Lock()
now := time.Now()
timeSinceLastCheck := now.Sub(lastUpdateCheckTime)
if timeSinceLastCheck < updateCheckInterval {
lastUpdateCheckMu.Unlock()
log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval)
return
}
lastUpdateCheckTime = now
lastUpdateCheckMu.Unlock()
if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
return

View File

@@ -19,6 +19,10 @@ import (
// - codex
// - qwen
// - iflow
// - kiro
// - github-copilot
// - kiro
// - amazonq
// - antigravity (returns static overrides only)
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
key := strings.ToLower(strings.TrimSpace(channel))
@@ -39,6 +43,12 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
return GetQwenModels()
case "iflow":
return GetIFlowModels()
case "github-copilot":
return GetGitHubCopilotModels()
case "kiro":
return GetKiroModels()
case "amazonq":
return GetAmazonQModels()
case "antigravity":
cfg := GetAntigravityModelConfig()
if len(cfg) == 0 {
@@ -83,6 +93,9 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
GetOpenAIModels(),
GetQwenModels(),
GetIFlowModels(),
GetGitHubCopilotModels(),
GetKiroModels(),
GetAmazonQModels(),
}
for _, models := range allModels {
for _, m := range models {
@@ -264,6 +277,18 @@ func GetGitHubCopilotModels() []*ModelInfo {
MaxCompletionTokens: 64000,
SupportedEndpoints: []string{"/chat/completions"},
},
{
ID: "claude-opus-4.6",
Object: "model",
Created: now,
OwnedBy: "github-copilot",
Type: "github-copilot",
DisplayName: "Claude Opus 4.6",
Description: "Anthropic Claude Opus 4.6 via GitHub Copilot",
ContextLength: 200000,
MaxCompletionTokens: 64000,
SupportedEndpoints: []string{"/chat/completions"},
},
{
ID: "claude-sonnet-4",
Object: "model",
@@ -363,6 +388,18 @@ func GetKiroModels() []*ModelInfo {
MaxCompletionTokens: 64000,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
{
ID: "kiro-claude-opus-4-6",
Object: "model",
Created: 1736899200, // 2025-01-15
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro Claude Opus 4.6",
Description: "Claude Opus 4.6 via Kiro (2.2x credit)",
ContextLength: 200000,
MaxCompletionTokens: 64000,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
{
ID: "kiro-claude-opus-4-5",
Object: "model",
@@ -412,6 +449,18 @@ func GetKiroModels() []*ModelInfo {
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
// --- Agentic Variants (Optimized for coding agents with chunked writes) ---
{
ID: "kiro-claude-opus-4-6-agentic",
Object: "model",
Created: 1736899200, // 2025-01-15
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro Claude Opus 4.6 (Agentic)",
Description: "Claude Opus 4.6 optimized for coding agents (chunked writes)",
ContextLength: 200000,
MaxCompletionTokens: 64000,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
{
ID: "kiro-claude-opus-4-5-agentic",
Object: "model",

View File

@@ -15,7 +15,7 @@ func GetClaudeModels() []*ModelInfo {
DisplayName: "Claude 4.5 Haiku",
ContextLength: 200000,
MaxCompletionTokens: 64000,
// Thinking: not supported for Haiku models
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
},
{
ID: "claude-sonnet-4-5-20250929",
@@ -28,6 +28,18 @@ func GetClaudeModels() []*ModelInfo {
MaxCompletionTokens: 64000,
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
},
{
ID: "claude-opus-4-6",
Object: "model",
Created: 1770318000, // 2026-02-05
OwnedBy: "anthropic",
Type: "claude",
DisplayName: "Claude 4.6 Opus",
Description: "Premium model combining maximum intelligence with practical performance",
ContextLength: 1000000,
MaxCompletionTokens: 128000,
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
},
{
ID: "claude-opus-4-5-20251101",
Object: "model",
@@ -716,6 +728,20 @@ func GetOpenAIModels() []*ModelInfo {
SupportedParameters: []string{"tools"},
Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high", "xhigh"}},
},
{
ID: "gpt-5.3-codex",
Object: "model",
Created: 1770307200,
OwnedBy: "openai",
Type: "openai",
Version: "gpt-5.3",
DisplayName: "GPT 5.3 Codex",
Description: "Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.",
ContextLength: 400000,
MaxCompletionTokens: 128000,
SupportedParameters: []string{"tools"},
Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high", "xhigh"}},
},
}
}
@@ -803,6 +829,7 @@ func GetIFlowModels() []*ModelInfo {
{ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport},
{ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
{ID: "iflow-rome-30ba3b", DisplayName: "iFlow-ROME", Description: "iFlow Rome 30BA3B model", Created: 1736899200},
{ID: "kimi-k2.5", DisplayName: "Kimi-K2.5", Description: "Moonshot Kimi K2.5", Created: 1769443200, Thinking: iFlowThinkingSupport},
}
models := make([]*ModelInfo, 0, len(entries))
for _, entry := range entries {
@@ -839,8 +866,50 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig {
"gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}},
"claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
"claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
"claude-opus-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 128000},
"claude-sonnet-4-5": {MaxCompletionTokens: 64000},
"gpt-oss-120b-medium": {},
"tab_flash_lite_preview": {},
}
}
// GetKimiModels returns the standard Kimi (Moonshot AI) model definitions
func GetKimiModels() []*ModelInfo {
return []*ModelInfo{
{
ID: "kimi-k2",
Object: "model",
Created: 1752192000, // 2025-07-11
OwnedBy: "moonshot",
Type: "kimi",
DisplayName: "Kimi K2",
Description: "Kimi K2 - Moonshot AI's flagship coding model",
ContextLength: 131072,
MaxCompletionTokens: 32768,
},
{
ID: "kimi-k2-thinking",
Object: "model",
Created: 1762387200, // 2025-11-06
OwnedBy: "moonshot",
Type: "kimi",
DisplayName: "Kimi K2 Thinking",
Description: "Kimi K2 Thinking - Extended reasoning model",
ContextLength: 131072,
MaxCompletionTokens: 32768,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
{
ID: "kimi-k2.5",
Object: "model",
Created: 1769472000, // 2026-01-26
OwnedBy: "moonshot",
Type: "kimi",
DisplayName: "Kimi K2.5",
Description: "Kimi K2.5 - Latest Moonshot AI coding model with improved capabilities",
ContextLength: 131072,
MaxCompletionTokens: 32768,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
}
}

View File

@@ -141,7 +141,7 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
URL: endpoint,
Method: http.MethodPost,
Headers: wsReq.Headers.Clone(),
Body: bytes.Clone(body.payload),
Body: body.payload,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
@@ -156,14 +156,14 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
}
recordAPIResponseMetadata(ctx, e.cfg, wsResp.Status, wsResp.Headers.Clone())
if len(wsResp.Body) > 0 {
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(wsResp.Body))
appendAPIResponseChunk(ctx, e.cfg, wsResp.Body)
}
if wsResp.Status < 200 || wsResp.Status >= 300 {
return resp, statusErr{code: wsResp.Status, msg: string(wsResp.Body)}
}
reporter.publish(ctx, parseGeminiUsage(wsResp.Body))
var param any
out := sdktranslator.TranslateNonStream(ctx, body.toFormat, opts.SourceFormat, req.Model, bytes.Clone(opts.OriginalRequest), bytes.Clone(translatedReq), bytes.Clone(wsResp.Body), &param)
out := sdktranslator.TranslateNonStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, wsResp.Body, &param)
resp = cliproxyexecutor.Response{Payload: ensureColonSpacedJSON([]byte(out))}
return resp, nil
}
@@ -199,7 +199,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
URL: endpoint,
Method: http.MethodPost,
Headers: wsReq.Headers.Clone(),
Body: bytes.Clone(body.payload),
Body: body.payload,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
@@ -225,7 +225,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
var body bytes.Buffer
if len(firstEvent.Payload) > 0 {
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(firstEvent.Payload))
appendAPIResponseChunk(ctx, e.cfg, firstEvent.Payload)
body.Write(firstEvent.Payload)
}
if firstEvent.Type == wsrelay.MessageTypeStreamEnd {
@@ -244,7 +244,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
metadataLogged = true
}
if len(event.Payload) > 0 {
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(event.Payload))
appendAPIResponseChunk(ctx, e.cfg, event.Payload)
body.Write(event.Payload)
}
if event.Type == wsrelay.MessageTypeStreamEnd {
@@ -274,12 +274,12 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
case wsrelay.MessageTypeStreamChunk:
if len(event.Payload) > 0 {
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(event.Payload))
appendAPIResponseChunk(ctx, e.cfg, event.Payload)
filtered := FilterSSEUsageMetadata(event.Payload)
if detail, ok := parseGeminiStreamUsage(filtered); ok {
reporter.publish(ctx, detail)
}
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, bytes.Clone(opts.OriginalRequest), translatedReq, bytes.Clone(filtered), &param)
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON([]byte(lines[i]))}
}
@@ -293,9 +293,9 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
metadataLogged = true
}
if len(event.Payload) > 0 {
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(event.Payload))
appendAPIResponseChunk(ctx, e.cfg, event.Payload)
}
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, bytes.Clone(opts.OriginalRequest), translatedReq, bytes.Clone(event.Payload), &param)
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON([]byte(lines[i]))}
}
@@ -350,7 +350,7 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A
URL: endpoint,
Method: http.MethodPost,
Headers: wsReq.Headers.Clone(),
Body: bytes.Clone(body.payload),
Body: body.payload,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
@@ -364,7 +364,7 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A
}
recordAPIResponseMetadata(ctx, e.cfg, resp.Status, resp.Headers.Clone())
if len(resp.Body) > 0 {
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(resp.Body))
appendAPIResponseChunk(ctx, e.cfg, resp.Body)
}
if resp.Status < 200 || resp.Status >= 300 {
return cliproxyexecutor.Response{}, statusErr{code: resp.Status, msg: string(resp.Body)}
@@ -373,7 +373,7 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A
if totalTokens <= 0 {
return cliproxyexecutor.Response{}, fmt.Errorf("wsrelay: totalTokens missing in response")
}
translated := sdktranslator.TranslateTokenCount(ctx, body.toFormat, opts.SourceFormat, totalTokens, bytes.Clone(resp.Body))
translated := sdktranslator.TranslateTokenCount(ctx, body.toFormat, opts.SourceFormat, totalTokens, resp.Body)
return cliproxyexecutor.Response{Payload: []byte(translated)}, nil
}
@@ -393,12 +393,13 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream)
payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
return nil, translatedPayload{}, err

View File

@@ -133,12 +133,13 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
from := opts.SourceFormat
to := sdktranslator.FromString("antigravity")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -230,7 +231,7 @@ attemptLoop:
reporter.publish(ctx, parseAntigravityUsage(bodyBytes))
var param any
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bodyBytes, &param)
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bodyBytes, &param)
resp = cliproxyexecutor.Response{Payload: []byte(converted)}
reporter.ensurePublished(ctx)
return resp, nil
@@ -274,12 +275,13 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
from := opts.SourceFormat
to := sdktranslator.FromString("antigravity")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -433,7 +435,7 @@ attemptLoop:
reporter.publish(ctx, parseAntigravityUsage(resp.Payload))
var param any
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, resp.Payload, &param)
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, resp.Payload, &param)
resp = cliproxyexecutor.Response{Payload: []byte(converted)}
reporter.ensurePublished(ctx)
@@ -665,12 +667,13 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
from := opts.SourceFormat
to := sdktranslator.FromString("antigravity")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -800,12 +803,12 @@ attemptLoop:
reporter.publish(ctx, detail)
}
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bytes.Clone(payload), &param)
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
}
tail := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, []byte("[DONE]"), &param)
tail := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), &param)
for i := range tail {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(tail[i])}
}
@@ -872,7 +875,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
respCtx := context.WithValue(ctx, "alt", opts.Alt)
// Prepare payload once (doesn't depend on baseURL)
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -1280,51 +1283,40 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
payload = geminiToAntigravity(modelName, payload, projectID)
payload, _ = sjson.SetBytes(payload, "model", modelName)
if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high") {
strJSON := string(payload)
paths := make([]string, 0)
util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths)
for _, p := range paths {
strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
}
// Use the centralized schema cleaner to handle unsupported keywords,
// const->enum conversion, and flattening of types/anyOf.
strJSON = util.CleanJSONSchemaForAntigravity(strJSON)
payload = []byte(strJSON)
} else {
strJSON := string(payload)
paths := make([]string, 0)
util.Walk(gjson.Parse(strJSON), "", "parametersJsonSchema", &paths)
for _, p := range paths {
strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
}
// Clean tool schemas for Gemini to remove unsupported JSON Schema keywords
// without adding empty-schema placeholders.
strJSON = util.CleanJSONSchemaForGemini(strJSON)
payload = []byte(strJSON)
useAntigravitySchema := strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high")
payloadStr := string(payload)
paths := make([]string, 0)
util.Walk(gjson.Parse(payloadStr), "", "parametersJsonSchema", &paths)
for _, p := range paths {
payloadStr, _ = util.RenameKey(payloadStr, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
}
if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-high") {
systemInstructionPartsResult := gjson.GetBytes(payload, "request.systemInstruction.parts")
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.role", "user")
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.0.text", systemInstruction)
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", systemInstruction))
if useAntigravitySchema {
payloadStr = util.CleanJSONSchemaForAntigravity(payloadStr)
} else {
payloadStr = util.CleanJSONSchemaForGemini(payloadStr)
}
if useAntigravitySchema {
systemInstructionPartsResult := gjson.Get(payloadStr, "request.systemInstruction.parts")
payloadStr, _ = sjson.Set(payloadStr, "request.systemInstruction.role", "user")
payloadStr, _ = sjson.Set(payloadStr, "request.systemInstruction.parts.0.text", systemInstruction)
payloadStr, _ = sjson.Set(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() {
payload, _ = sjson.SetRawBytes(payload, "request.systemInstruction.parts.-1", []byte(partResult.Raw))
payloadStr, _ = sjson.SetRaw(payloadStr, "request.systemInstruction.parts.-1", partResult.Raw)
}
}
}
if strings.Contains(modelName, "claude") {
payload, _ = sjson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
payloadStr, _ = sjson.Set(payloadStr, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
} else {
payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.maxOutputTokens")
payloadStr, _ = sjson.Delete(payloadStr, "request.generationConfig.maxOutputTokens")
}
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), strings.NewReader(payloadStr))
if errReq != nil {
return nil, errReq
}
@@ -1346,11 +1338,15 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
var payloadLog []byte
if e.cfg != nil && e.cfg.RequestLog {
payloadLog = []byte(payloadStr)
}
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
URL: requestURL.String(),
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
Body: payload,
Body: payloadLog,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,

View File

@@ -100,12 +100,13 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
to := sdktranslator.FromString("claude")
// Use streaming translation to preserve function calling, except for claude.
stream := from != to
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
@@ -216,7 +217,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
to,
from,
req.Model,
bytes.Clone(opts.OriginalRequest),
opts.OriginalRequest,
bodyForTranslation,
data,
&param,
@@ -240,12 +241,13 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
defer reporter.trackFailure(ctx, &err)
from := opts.SourceFormat
to := sdktranslator.FromString("claude")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
@@ -381,7 +383,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
to,
from,
req.Model,
bytes.Clone(opts.OriginalRequest),
opts.OriginalRequest,
bodyForTranslation,
bytes.Clone(line),
&param,
@@ -411,7 +413,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
to := sdktranslator.FromString("claude")
// Use streaming translation to preserve function calling, except for claude.
stream := from != to
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, stream)
body, _ = sjson.SetBytes(body, "model", baseModel)
if !strings.HasPrefix(baseModel, "claude-3-5-haiku") {

View File

@@ -27,6 +27,11 @@ import (
"github.com/google/uuid"
)
const (
codexClientVersion = "0.98.0"
codexUserAgent = "codex_cli_rs/0.98.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
)
var dataTag = []byte("data:")
// CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).
@@ -88,12 +93,13 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
from := opts.SourceFormat
to := sdktranslator.FromString("codex")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -176,7 +182,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(originalPayload), body, line, &param)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, line, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -197,12 +203,13 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
from := opts.SourceFormat
to := sdktranslator.FromString("openai-response")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -265,7 +272,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
reporter.publish(ctx, parseOpenAIUsage(data))
reporter.ensurePublished(ctx)
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(originalPayload), body, data, &param)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, data, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -286,12 +293,13 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
from := opts.SourceFormat
to := sdktranslator.FromString("codex")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -378,7 +386,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
}
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(originalPayload), body, bytes.Clone(line), &param)
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
@@ -397,7 +405,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
from := opts.SourceFormat
to := sdktranslator.FromString("codex")
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
body, err := thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -634,10 +642,10 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
ginHeaders = ginCtx.Request.Header
}
misc.EnsureHeader(r.Header, ginHeaders, "Version", "0.21.0")
misc.EnsureHeader(r.Header, ginHeaders, "Version", codexClientVersion)
misc.EnsureHeader(r.Header, ginHeaders, "Openai-Beta", "responses=experimental")
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464")
misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", codexUserAgent)
if stream {
r.Header.Set("Accept", "text/event-stream")

View File

@@ -119,12 +119,13 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
from := opts.SourceFormat
to := sdktranslator.FromString("gemini-cli")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
basePayload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
basePayload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
basePayload, err = thinking.ApplyThinking(basePayload, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -223,7 +224,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 {
reporter.publish(ctx, parseGeminiCLIUsage(data))
var param any
out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, &param)
out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, opts.OriginalRequest, payload, data, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -272,12 +273,13 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
from := opts.SourceFormat
to := sdktranslator.FromString("gemini-cli")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
basePayload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
basePayload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
basePayload, err = thinking.ApplyThinking(basePayload, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -399,14 +401,14 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
reporter.publish(ctx, detail)
}
if bytes.HasPrefix(line, dataTag) {
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone(line), &param)
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, bytes.Clone(line), &param)
for i := range segments {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
}
}
}
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone([]byte("[DONE]")), &param)
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), &param)
for i := range segments {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
}
@@ -428,12 +430,12 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
appendAPIResponseChunk(ctx, e.cfg, data)
reporter.publish(ctx, parseGeminiCLIUsage(data))
var param any
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), reqBody, data, &param)
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, data, &param)
for i := range segments {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
}
segments = sdktranslator.TranslateStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone([]byte("[DONE]")), &param)
segments = sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), &param)
for i := range segments {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
}
@@ -485,7 +487,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
// The loop variable attemptModel is only used as the concrete model id sent to the upstream
// Gemini CLI endpoint when iterating fallback variants.
for range models {
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
payload, err = thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {

View File

@@ -116,12 +116,13 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
// Official Gemini API via API key or OAuth bearer
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -203,7 +204,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
appendAPIResponseChunk(ctx, e.cfg, data)
reporter.publish(ctx, parseGeminiUsage(data))
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -222,12 +223,13 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -318,12 +320,12 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if detail, ok := parseGeminiStreamUsage(payload); ok {
reporter.publish(ctx, detail)
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(payload), &param)
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
}
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone([]byte("[DONE]")), &param)
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
}
@@ -344,7 +346,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {

View File

@@ -318,12 +318,13 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body = sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body = sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -417,7 +418,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -432,12 +433,13 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -521,7 +523,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
appendAPIResponseChunk(ctx, e.cfg, data)
reporter.publish(ctx, parseGeminiUsage(data))
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -536,12 +538,13 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -632,12 +635,12 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
if detail, ok := parseGeminiStreamUsage(line); ok {
reporter.publish(ctx, detail)
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
}
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, []byte("[DONE]"), &param)
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
}
@@ -660,12 +663,13 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -756,12 +760,12 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
if detail, ok := parseGeminiStreamUsage(line); ok {
reporter.publish(ctx, detail)
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
}
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, []byte("[DONE]"), &param)
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
}
@@ -781,7 +785,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -865,7 +869,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -1003,6 +1007,8 @@ func vertexBaseURL(location string) string {
loc := strings.TrimSpace(location)
if loc == "" {
loc = "us-central1"
} else if loc == "global" {
return "https://aiplatform.googleapis.com"
}
return fmt.Sprintf("https://%s-aiplatform.googleapis.com", loc)
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
@@ -33,11 +34,11 @@ const (
maxScannerBufferSize = 20_971_520
// Copilot API header values.
copilotUserAgent = "GithubCopilot/1.0"
copilotEditorVersion = "vscode/1.100.0"
copilotPluginVersion = "copilot/1.300.0"
copilotUserAgent = "GitHubCopilotChat/0.35.0"
copilotEditorVersion = "vscode/1.107.0"
copilotPluginVersion = "copilot-chat/0.35.0"
copilotIntegrationID = "vscode-chat"
copilotOpenAIIntent = "conversation-panel"
copilotOpenAIIntent = "conversation-edits"
)
// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
@@ -77,7 +78,7 @@ func (e *GitHubCopilotExecutor) PrepareRequest(req *http.Request, auth *cliproxy
if errToken != nil {
return errToken
}
e.applyHeaders(req, apiToken)
e.applyHeaders(req, apiToken, nil)
return nil
}
@@ -120,6 +121,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
body = e.normalizeModel(req.Model, body)
body = flattenAssistantContent(body)
requestedModel := payloadRequestedModel(opts, req.Model)
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
body, _ = sjson.SetBytes(body, "stream", false)
@@ -133,7 +135,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.
if err != nil {
return resp, err
}
e.applyHeaders(httpReq, apiToken)
e.applyHeaders(httpReq, apiToken, body)
// Add Copilot-Vision-Request header if the request contains vision content
if detectVisionContent(body) {
@@ -225,6 +227,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
body = e.normalizeModel(req.Model, body)
body = flattenAssistantContent(body)
requestedModel := payloadRequestedModel(opts, req.Model)
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
body, _ = sjson.SetBytes(body, "stream", true)
@@ -242,7 +245,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox
if err != nil {
return nil, err
}
e.applyHeaders(httpReq, apiToken)
e.applyHeaders(httpReq, apiToken, body)
// Add Copilot-Vision-Request header if the request contains vision content
if detectVisionContent(body) {
@@ -414,7 +417,7 @@ func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *clipro
}
// applyHeaders sets the required headers for GitHub Copilot API requests.
func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {
func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string, body []byte) {
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+apiToken)
r.Header.Set("Accept", "application/json")
@@ -424,6 +427,20 @@ func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {
r.Header.Set("Openai-Intent", copilotOpenAIIntent)
r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
r.Header.Set("X-Request-Id", uuid.NewString())
initiator := "user"
if len(body) > 0 {
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
arr := messages.Array()
if len(arr) > 0 {
lastRole := arr[len(arr)-1].Get("role").String()
if lastRole != "" && lastRole != "user" {
initiator = "agent"
}
}
}
}
r.Header.Set("X-Initiator", initiator)
}
// detectVisionContent checks if the request body contains vision/image content.
@@ -464,6 +481,38 @@ func useGitHubCopilotResponsesEndpoint(sourceFormat sdktranslator.Format) bool {
return sourceFormat.String() == "openai-response"
}
// flattenAssistantContent converts assistant message content from array format
// to a joined string. GitHub Copilot requires assistant content as a string;
// sending it as an array causes Claude models to re-answer all previous prompts.
func flattenAssistantContent(body []byte) []byte {
messages := gjson.GetBytes(body, "messages")
if !messages.Exists() || !messages.IsArray() {
return body
}
result := body
for i, msg := range messages.Array() {
if msg.Get("role").String() != "assistant" {
continue
}
content := msg.Get("content")
if !content.Exists() || !content.IsArray() {
continue
}
var textParts []string
for _, part := range content.Array() {
if part.Get("type").String() == "text" {
if t := part.Get("text").String(); t != "" {
textParts = append(textParts, t)
}
}
}
joined := strings.Join(textParts, "")
path := fmt.Sprintf("messages.%d.content", i)
result, _ = sjson.SetBytes(result, path, joined)
}
return result
}
// isHTTPSuccess checks if the status code indicates success (2xx).
func isHTTPSuccess(statusCode int) bool {
return statusCode >= 200 && statusCode < 300

View File

@@ -87,12 +87,13 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), "iflow", e.Identifier())
@@ -163,7 +164,7 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
var param any
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
// the original model name in the response for client compatibility.
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -189,12 +190,13 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), "iflow", e.Identifier())
@@ -274,7 +276,7 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
if detail, ok := parseOpenAIStreamUsage(line); ok {
reporter.publish(ctx, detail)
}
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
@@ -296,7 +298,7 @@ func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
enc, err := tokenizerForModel(baseModel)
if err != nil {

View File

@@ -0,0 +1,618 @@
package executor
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
kimiauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// KimiExecutor is a stateless executor for Kimi API using OpenAI-compatible chat completions.
type KimiExecutor struct {
ClaudeExecutor
cfg *config.Config
}
// NewKimiExecutor creates a new Kimi executor.
func NewKimiExecutor(cfg *config.Config) *KimiExecutor { return &KimiExecutor{cfg: cfg} }
// Identifier returns the executor identifier.
func (e *KimiExecutor) Identifier() string { return "kimi" }
// PrepareRequest injects Kimi credentials into the outgoing HTTP request.
func (e *KimiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
return nil
}
token := kimiCreds(auth)
if strings.TrimSpace(token) != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return nil
}
// HttpRequest injects Kimi credentials into the request and executes it.
func (e *KimiExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
if req == nil {
return nil, fmt.Errorf("kimi executor: request is nil")
}
if ctx == nil {
ctx = req.Context()
}
httpReq := req.WithContext(ctx)
if err := e.PrepareRequest(httpReq, auth); err != nil {
return nil, err
}
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
return httpClient.Do(httpReq)
}
// Execute performs a non-streaming chat completion request to Kimi.
func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
from := opts.SourceFormat
if from.String() == "claude" {
auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL
return e.ClaudeExecutor.Execute(ctx, auth, req, opts)
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
token := kimiCreds(auth)
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
defer reporter.trackFailure(ctx, &err)
to := sdktranslator.FromString("openai")
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayloadSource = opts.OriginalRequest
}
originalPayload := bytes.Clone(originalPayloadSource)
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
// Strip kimi- prefix for upstream API
upstreamModel := stripKimiPrefix(baseModel)
body, err = sjson.SetBytes(body, "model", upstreamModel)
if err != nil {
return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err)
}
body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier())
if err != nil {
return resp, err
}
requestedModel := payloadRequestedModel(opts, req.Model)
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
body, err = normalizeKimiToolMessageLinks(body)
if err != nil {
return resp, err
}
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return resp, err
}
applyKimiHeadersWithAuth(httpReq, token, false, auth)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
Body: body,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
recordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("kimi executor: close response body error: %v", errClose)
}
}()
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
appendAPIResponseChunk(ctx, e.cfg, b)
logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
return resp, err
}
data, err := io.ReadAll(httpResp.Body)
if err != nil {
recordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
appendAPIResponseChunk(ctx, e.cfg, data)
reporter.publish(ctx, parseOpenAIUsage(data))
var param any
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
// the original model name in the response for client compatibility.
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
// ExecuteStream performs a streaming chat completion request to Kimi.
func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
from := opts.SourceFormat
if from.String() == "claude" {
auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL
return e.ClaudeExecutor.ExecuteStream(ctx, auth, req, opts)
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
token := kimiCreds(auth)
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
defer reporter.trackFailure(ctx, &err)
to := sdktranslator.FromString("openai")
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayloadSource = opts.OriginalRequest
}
originalPayload := bytes.Clone(originalPayloadSource)
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
// Strip kimi- prefix for upstream API
upstreamModel := stripKimiPrefix(baseModel)
body, err = sjson.SetBytes(body, "model", upstreamModel)
if err != nil {
return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err)
}
body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier())
if err != nil {
return nil, err
}
body, err = sjson.SetBytes(body, "stream_options.include_usage", true)
if err != nil {
return nil, fmt.Errorf("kimi executor: failed to set stream_options in payload: %w", err)
}
requestedModel := payloadRequestedModel(opts, req.Model)
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
body, err = normalizeKimiToolMessageLinks(body)
if err != nil {
return nil, err
}
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
applyKimiHeadersWithAuth(httpReq, token, true, auth)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
Body: body,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
recordAPIResponseError(ctx, e.cfg, err)
return nil, err
}
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
appendAPIResponseChunk(ctx, e.cfg, b)
logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("kimi executor: close response body error: %v", errClose)
}
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
return nil, err
}
out := make(chan cliproxyexecutor.StreamChunk)
stream = out
go func() {
defer close(out)
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("kimi executor: close response body error: %v", errClose)
}
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 1_048_576) // 1MB
var param any
for scanner.Scan() {
line := scanner.Bytes()
appendAPIResponseChunk(ctx, e.cfg, line)
if detail, ok := parseOpenAIStreamUsage(line); ok {
reporter.publish(ctx, detail)
}
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
}
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
for i := range doneChunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(doneChunks[i])}
}
if errScan := scanner.Err(); errScan != nil {
recordAPIResponseError(ctx, e.cfg, errScan)
reporter.publishFailure(ctx)
out <- cliproxyexecutor.StreamChunk{Err: errScan}
}
}()
return stream, nil
}
// CountTokens estimates token count for Kimi requests.
func (e *KimiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL
return e.ClaudeExecutor.CountTokens(ctx, auth, req, opts)
}
func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
if len(body) == 0 || !gjson.ValidBytes(body) {
return body, nil
}
messages := gjson.GetBytes(body, "messages")
if !messages.Exists() || !messages.IsArray() {
return body, nil
}
out := body
pending := make([]string, 0)
patched := 0
patchedReasoning := 0
ambiguous := 0
latestReasoning := ""
hasLatestReasoning := false
removePending := func(id string) {
for idx := range pending {
if pending[idx] != id {
continue
}
pending = append(pending[:idx], pending[idx+1:]...)
return
}
}
msgs := messages.Array()
for msgIdx := range msgs {
msg := msgs[msgIdx]
role := strings.TrimSpace(msg.Get("role").String())
switch role {
case "assistant":
reasoning := msg.Get("reasoning_content")
if reasoning.Exists() {
reasoningText := reasoning.String()
if strings.TrimSpace(reasoningText) != "" {
latestReasoning = reasoningText
hasLatestReasoning = true
}
}
toolCalls := msg.Get("tool_calls")
if !toolCalls.Exists() || !toolCalls.IsArray() || len(toolCalls.Array()) == 0 {
continue
}
if !reasoning.Exists() || strings.TrimSpace(reasoning.String()) == "" {
reasoningText := fallbackAssistantReasoning(msg, hasLatestReasoning, latestReasoning)
path := fmt.Sprintf("messages.%d.reasoning_content", msgIdx)
next, err := sjson.SetBytes(out, path, reasoningText)
if err != nil {
return body, fmt.Errorf("kimi executor: failed to set assistant reasoning_content: %w", err)
}
out = next
patchedReasoning++
}
for _, tc := range toolCalls.Array() {
id := strings.TrimSpace(tc.Get("id").String())
if id == "" {
continue
}
pending = append(pending, id)
}
case "tool":
toolCallID := strings.TrimSpace(msg.Get("tool_call_id").String())
if toolCallID == "" {
toolCallID = strings.TrimSpace(msg.Get("call_id").String())
if toolCallID != "" {
path := fmt.Sprintf("messages.%d.tool_call_id", msgIdx)
next, err := sjson.SetBytes(out, path, toolCallID)
if err != nil {
return body, fmt.Errorf("kimi executor: failed to set tool_call_id from call_id: %w", err)
}
out = next
patched++
}
}
if toolCallID == "" {
if len(pending) == 1 {
toolCallID = pending[0]
path := fmt.Sprintf("messages.%d.tool_call_id", msgIdx)
next, err := sjson.SetBytes(out, path, toolCallID)
if err != nil {
return body, fmt.Errorf("kimi executor: failed to infer tool_call_id: %w", err)
}
out = next
patched++
} else if len(pending) > 1 {
ambiguous++
}
}
if toolCallID != "" {
removePending(toolCallID)
}
}
}
if patched > 0 || patchedReasoning > 0 {
log.WithFields(log.Fields{
"patched_tool_messages": patched,
"patched_reasoning_messages": patchedReasoning,
}).Debug("kimi executor: normalized tool message fields")
}
if ambiguous > 0 {
log.WithFields(log.Fields{
"ambiguous_tool_messages": ambiguous,
"pending_tool_calls": len(pending),
}).Warn("kimi executor: tool messages missing tool_call_id with ambiguous candidates")
}
return out, nil
}
func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string {
if hasLatest && strings.TrimSpace(latest) != "" {
return latest
}
content := msg.Get("content")
if content.Type == gjson.String {
if text := strings.TrimSpace(content.String()); text != "" {
return text
}
}
if content.IsArray() {
parts := make([]string, 0, len(content.Array()))
for _, item := range content.Array() {
text := strings.TrimSpace(item.Get("text").String())
if text == "" {
continue
}
parts = append(parts, text)
}
if len(parts) > 0 {
return strings.Join(parts, "\n")
}
}
return "[reasoning unavailable]"
}
// Refresh refreshes the Kimi token using the refresh token.
func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("kimi executor: refresh called")
if auth == nil {
return nil, fmt.Errorf("kimi executor: auth is nil")
}
// Expect refresh_token in metadata for OAuth-based accounts
var refreshToken string
if auth.Metadata != nil {
if v, ok := auth.Metadata["refresh_token"].(string); ok && strings.TrimSpace(v) != "" {
refreshToken = v
}
}
if strings.TrimSpace(refreshToken) == "" {
// Nothing to refresh
return auth, nil
}
client := kimiauth.NewDeviceFlowClientWithDeviceID(e.cfg, resolveKimiDeviceID(auth))
td, err := client.RefreshToken(ctx, refreshToken)
if err != nil {
return nil, err
}
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["access_token"] = td.AccessToken
if td.RefreshToken != "" {
auth.Metadata["refresh_token"] = td.RefreshToken
}
if td.ExpiresAt > 0 {
exp := time.Unix(td.ExpiresAt, 0).UTC().Format(time.RFC3339)
auth.Metadata["expired"] = exp
}
auth.Metadata["type"] = "kimi"
now := time.Now().Format(time.RFC3339)
auth.Metadata["last_refresh"] = now
return auth, nil
}
// applyKimiHeaders sets required headers for Kimi API requests.
// Headers match kimi-cli client for compatibility.
func applyKimiHeaders(r *http.Request, token string, stream bool) {
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+token)
// Match kimi-cli headers exactly
r.Header.Set("User-Agent", "KimiCLI/1.10.6")
r.Header.Set("X-Msh-Platform", "kimi_cli")
r.Header.Set("X-Msh-Version", "1.10.6")
r.Header.Set("X-Msh-Device-Name", getKimiHostname())
r.Header.Set("X-Msh-Device-Model", getKimiDeviceModel())
r.Header.Set("X-Msh-Device-Id", getKimiDeviceID())
if stream {
r.Header.Set("Accept", "text/event-stream")
return
}
r.Header.Set("Accept", "application/json")
}
func resolveKimiDeviceIDFromAuth(auth *cliproxyauth.Auth) string {
if auth == nil || auth.Metadata == nil {
return ""
}
deviceIDRaw, ok := auth.Metadata["device_id"]
if !ok {
return ""
}
deviceID, ok := deviceIDRaw.(string)
if !ok {
return ""
}
return strings.TrimSpace(deviceID)
}
func resolveKimiDeviceIDFromStorage(auth *cliproxyauth.Auth) string {
if auth == nil {
return ""
}
storage, ok := auth.Storage.(*kimiauth.KimiTokenStorage)
if !ok || storage == nil {
return ""
}
return strings.TrimSpace(storage.DeviceID)
}
func resolveKimiDeviceID(auth *cliproxyauth.Auth) string {
deviceID := resolveKimiDeviceIDFromAuth(auth)
if deviceID != "" {
return deviceID
}
return resolveKimiDeviceIDFromStorage(auth)
}
func applyKimiHeadersWithAuth(r *http.Request, token string, stream bool, auth *cliproxyauth.Auth) {
applyKimiHeaders(r, token, stream)
if deviceID := resolveKimiDeviceID(auth); deviceID != "" {
r.Header.Set("X-Msh-Device-Id", deviceID)
}
}
// getKimiHostname returns the machine hostname.
func getKimiHostname() string {
hostname, err := os.Hostname()
if err != nil {
return "unknown"
}
return hostname
}
// getKimiDeviceModel returns a device model string matching kimi-cli format.
func getKimiDeviceModel() string {
return fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH)
}
// getKimiDeviceID returns a stable device ID, matching kimi-cli storage location.
func getKimiDeviceID() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return "cli-proxy-api-device"
}
// Check kimi-cli's device_id location first (platform-specific)
var kimiShareDir string
switch runtime.GOOS {
case "darwin":
kimiShareDir = filepath.Join(homeDir, "Library", "Application Support", "kimi")
case "windows":
appData := os.Getenv("APPDATA")
if appData == "" {
appData = filepath.Join(homeDir, "AppData", "Roaming")
}
kimiShareDir = filepath.Join(appData, "kimi")
default: // linux and other unix-like
kimiShareDir = filepath.Join(homeDir, ".local", "share", "kimi")
}
deviceIDPath := filepath.Join(kimiShareDir, "device_id")
if data, err := os.ReadFile(deviceIDPath); err == nil {
return strings.TrimSpace(string(data))
}
return "cli-proxy-api-device"
}
// kimiCreds extracts the access token from auth.
func kimiCreds(a *cliproxyauth.Auth) (token string) {
if a == nil {
return ""
}
// Check metadata first (OAuth flow stores tokens here)
if a.Metadata != nil {
if v, ok := a.Metadata["access_token"].(string); ok && strings.TrimSpace(v) != "" {
return v
}
}
// Fallback to attributes (API key style)
if a.Attributes != nil {
if v := a.Attributes["access_token"]; v != "" {
return v
}
if v := a.Attributes["api_key"]; v != "" {
return v
}
}
return ""
}
// stripKimiPrefix removes the "kimi-" prefix from model names for the upstream API.
func stripKimiPrefix(model string) string {
model = strings.TrimSpace(model)
if strings.HasPrefix(strings.ToLower(model), "kimi-") {
return model[5:]
}
return model
}

View File

@@ -0,0 +1,205 @@
package executor
import (
"testing"
"github.com/tidwall/gjson"
)
func TestNormalizeKimiToolMessageLinks_UsesCallIDFallback(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","tool_calls":[{"id":"list_directory:1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
{"role":"tool","call_id":"list_directory:1","content":"[]"}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
got := gjson.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")
}
}
func TestNormalizeKimiToolMessageLinks_InferSinglePendingID(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","tool_calls":[{"id":"call_123","type":"function","function":{"name":"read_file","arguments":"{}"}}]},
{"role":"tool","content":"file-content"}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
got := gjson.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")
}
}
func TestNormalizeKimiToolMessageLinks_AmbiguousMissingIDIsNotInferred(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","tool_calls":[
{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}},
{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}
]},
{"role":"tool","content":"result-without-id"}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
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())
}
}
func TestNormalizeKimiToolMessageLinks_PreservesExistingToolCallID(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
{"role":"tool","tool_call_id":"call_1","call_id":"different-id","content":"result"}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
got := gjson.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")
}
}
func TestNormalizeKimiToolMessageLinks_InheritsPreviousReasoningForAssistantToolCalls(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","content":"plan","reasoning_content":"previous reasoning"},
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
got := gjson.GetBytes(out, "messages.1.reasoning_content").String()
if got != "previous reasoning" {
t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "previous reasoning")
}
}
func TestNormalizeKimiToolMessageLinks_InsertsFallbackReasoningWhenMissing(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
reasoning := gjson.GetBytes(out, "messages.0.reasoning_content")
if !reasoning.Exists() {
t.Fatalf("messages.0.reasoning_content should exist")
}
if reasoning.String() != "[reasoning unavailable]" {
t.Fatalf("messages.0.reasoning_content = %q, want %q", reasoning.String(), "[reasoning unavailable]")
}
}
func TestNormalizeKimiToolMessageLinks_UsesContentAsReasoningFallback(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","content":[{"type":"text","text":"first line"},{"type":"text","text":"second line"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
got := gjson.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")
}
}
func TestNormalizeKimiToolMessageLinks_ReplacesEmptyReasoningContent(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","content":"assistant summary","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":""}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
if got != "assistant summary" {
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary")
}
}
func TestNormalizeKimiToolMessageLinks_PreservesExistingAssistantReasoning(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"keep me"}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
if got != "keep me" {
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "keep me")
}
}
func TestNormalizeKimiToolMessageLinks_RepairsIDsAndReasoningTogether(t *testing.T) {
body := []byte(`{
"messages":[
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"r1"},
{"role":"tool","call_id":"call_1","content":"[]"},
{"role":"assistant","tool_calls":[{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}]},
{"role":"tool","call_id":"call_2","content":"file"}
]
}`)
out, err := normalizeKimiToolMessageLinks(body)
if err != nil {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
if got := gjson.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" {
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" {
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "r1")
}
}

View File

@@ -1681,6 +1681,7 @@ func (e *KiroExecutor) mapModelToKiro(model string) string {
modelMap := map[string]string{
// Amazon Q format (amazonq- prefix) - same API as Kiro
"amazonq-auto": "auto",
"amazonq-claude-opus-4-6": "claude-opus-4.6",
"amazonq-claude-opus-4-5": "claude-opus-4.5",
"amazonq-claude-sonnet-4-5": "claude-sonnet-4.5",
"amazonq-claude-sonnet-4-5-20250929": "claude-sonnet-4.5",
@@ -1688,6 +1689,7 @@ func (e *KiroExecutor) mapModelToKiro(model string) string {
"amazonq-claude-sonnet-4-20250514": "claude-sonnet-4",
"amazonq-claude-haiku-4-5": "claude-haiku-4.5",
// Kiro format (kiro- prefix) - valid model names that should be preserved
"kiro-claude-opus-4-6": "claude-opus-4.6",
"kiro-claude-opus-4-5": "claude-opus-4.5",
"kiro-claude-sonnet-4-5": "claude-sonnet-4.5",
"kiro-claude-sonnet-4-5-20250929": "claude-sonnet-4.5",
@@ -1696,6 +1698,8 @@ func (e *KiroExecutor) mapModelToKiro(model string) string {
"kiro-claude-haiku-4-5": "claude-haiku-4.5",
"kiro-auto": "auto",
// Native format (no prefix) - used by Kiro IDE directly
"claude-opus-4-6": "claude-opus-4.6",
"claude-opus-4.6": "claude-opus-4.6",
"claude-opus-4-5": "claude-opus-4.5",
"claude-opus-4.5": "claude-opus-4.5",
"claude-haiku-4-5": "claude-haiku-4.5",
@@ -1707,10 +1711,12 @@ func (e *KiroExecutor) mapModelToKiro(model string) string {
"claude-sonnet-4-20250514": "claude-sonnet-4",
"auto": "auto",
// Agentic variants (same backend model IDs, but with special system prompt)
"claude-opus-4.6-agentic": "claude-opus-4.6",
"claude-opus-4.5-agentic": "claude-opus-4.5",
"claude-sonnet-4.5-agentic": "claude-sonnet-4.5",
"claude-sonnet-4-agentic": "claude-sonnet-4",
"claude-haiku-4.5-agentic": "claude-haiku-4.5",
"kiro-claude-opus-4-6-agentic": "claude-opus-4.6",
"kiro-claude-opus-4-5-agentic": "claude-opus-4.5",
"kiro-claude-sonnet-4-5-agentic": "claude-sonnet-4.5",
"kiro-claude-sonnet-4-agentic": "claude-sonnet-4",
@@ -2096,6 +2102,22 @@ func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroclaude.Ki
}
}
case "contextUsageEvent":
// Handle context usage events from Kiro API
// Format: {"contextUsageEvent": {"contextUsagePercentage": 0.53}}
if ctxUsage, ok := event["contextUsageEvent"].(map[string]interface{}); ok {
if ctxPct, ok := ctxUsage["contextUsagePercentage"].(float64); ok {
upstreamContextPercentage = ctxPct
log.Debugf("kiro: parseEventStream received contextUsageEvent: %.2f%%", ctxPct*100)
}
} else {
// Try direct field (fallback)
if ctxPct, ok := event["contextUsagePercentage"].(float64); ok {
upstreamContextPercentage = ctxPct
log.Debugf("kiro: parseEventStream received contextUsagePercentage (direct): %.2f%%", ctxPct*100)
}
}
case "error", "exception", "internalServerException", "invalidStateEvent":
// Handle error events from Kiro API stream
errMsg := ""
@@ -2442,8 +2464,9 @@ func (e *KiroExecutor) extractEventTypeFromBytes(headers []byte) string {
func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out chan<- cliproxyexecutor.StreamChunk, targetFormat sdktranslator.Format, model string, originalReq, claudeBody []byte, reporter *usageReporter, thinkingEnabled bool) {
reader := bufio.NewReaderSize(body, 20*1024*1024) // 20MB buffer to match other providers
var totalUsage usage.Detail
var hasToolUses bool // Track if any tool uses were emitted
var upstreamStopReason string // Track stop_reason from upstream events
var hasToolUses bool // Track if any tool uses were emitted
var hasTruncatedTools bool // Track if any tool uses were truncated
var upstreamStopReason string // Track stop_reason from upstream events
// Tool use state tracking for input buffering and deduplication
processedIDs := make(map[string]bool)
@@ -2698,6 +2721,22 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
case "contextUsageEvent":
// Handle context usage events from Kiro API
// Format: {"contextUsageEvent": {"contextUsagePercentage": 0.53}}
if ctxUsage, ok := event["contextUsageEvent"].(map[string]interface{}); ok {
if ctxPct, ok := ctxUsage["contextUsagePercentage"].(float64); ok {
upstreamContextPercentage = ctxPct
log.Debugf("kiro: streamToChannel received contextUsageEvent: %.2f%%", ctxPct*100)
}
} else {
// Try direct field (fallback)
if ctxPct, ok := event["contextUsagePercentage"].(float64); ok {
upstreamContextPercentage = ctxPct
log.Debugf("kiro: streamToChannel received contextUsagePercentage (direct): %.2f%%", ctxPct*100)
}
}
case "error", "exception", "internalServerException":
// Handle error events from Kiro API stream
errMsg := ""
@@ -3221,40 +3260,16 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
_ = signature // Signature can be used for verification if needed
case "toolUseEvent":
// Debug: log raw toolUseEvent payload for large tool inputs
if log.IsLevelEnabled(log.DebugLevel) {
payloadStr := string(payload)
if len(payloadStr) > 500 {
payloadStr = payloadStr[:500] + "...[truncated]"
}
log.Debugf("kiro: raw toolUseEvent payload (%d bytes): %s", len(payload), payloadStr)
}
// Handle dedicated tool use events with input buffering
completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs)
currentToolUse = newState
// Emit completed tool uses
for _, tu := range completedToolUses {
// Check for truncated write marker - emit as a Bash tool that echoes the error
// This way Claude Code will execute it, see the error, and the agent can retry
if tu.Name == "__truncated_write__" {
filePath := ""
if fp, ok := tu.Input["file_path"].(string); ok && fp != "" {
filePath = fp
}
// Create a Bash tool that echoes the error message
// This will be executed by Claude Code and the agent will see the result
var errorMsg string
if filePath != "" {
errorMsg = fmt.Sprintf("echo '[WRITE TOOL ERROR] The file content for \"%s\" is too large to be transmitted by the upstream API. You MUST retry by writing the file in smaller chunks: First use Write to create the file with the first 700 lines, then use multiple Edit operations to append the remaining content in chunks of ~700 lines each.'", filePath)
} else {
errorMsg = "echo '[WRITE TOOL ERROR] The file content is too large to be transmitted by the upstream API. The Write tool input was truncated. You MUST retry by writing the file in smaller chunks: First use Write to create the file with the first 700 lines, then use multiple Edit operations to append the remaining content in chunks of ~700 lines each.'"
}
log.Warnf("kiro: converting truncated write to Bash echo for file: %s", filePath)
hasToolUses = true
// Check if this tool was truncated - emit with SOFT_LIMIT_REACHED marker
if tu.IsTruncated {
hasTruncatedTools = true
log.Infof("kiro: streamToChannel emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", tu.Name, tu.ToolUseID)
// Close text block if open
if isTextBlockOpen && contentBlockIndex >= 0 {
@@ -3270,8 +3285,8 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
contentBlockIndex++
// Emit as Bash tool_use
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, "Bash")
// Emit tool_use with SOFT_LIMIT_REACHED marker input
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
@@ -3279,16 +3294,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
// Emit the Bash command as input
bashInput := map[string]interface{}{
"command": errorMsg,
// Build SOFT_LIMIT_REACHED marker input
markerInput := map[string]interface{}{
"_status": "SOFT_LIMIT_REACHED",
"_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.",
}
inputJSON, err := json.Marshal(bashInput)
if err != nil {
log.Errorf("kiro: failed to marshal bash input for truncated write error: %v", err)
continue
}
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
markerJSON, _ := json.Marshal(markerInput)
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(markerJSON), contentBlockIndex)
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
@@ -3296,6 +3309,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
// Close tool_use block
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
for _, chunk := range sseData {
@@ -3304,7 +3318,8 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
continue // Skip the normal tool_use emission
hasToolUses = true // Keep this so stop_reason = tool_use
continue
}
hasToolUses = true
@@ -3605,7 +3620,12 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
// Determine stop reason: prefer upstream, then detect tool_use, default to end_turn
// SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop
stopReason := upstreamStopReason
if hasTruncatedTools {
// Log that we're using SOFT_LIMIT_REACHED approach
log.Infof("kiro: streamToChannel using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use for truncated tools")
}
if stopReason == "" {
if hasToolUses {
stopReason = "tool_use"

View File

@@ -80,7 +80,7 @@ func recordAPIRequest(ctx context.Context, cfg *config.Config, info upstreamRequ
writeHeaders(builder, info.Headers)
builder.WriteString("\nBody:\n")
if len(info.Body) > 0 {
builder.WriteString(string(bytes.Clone(info.Body)))
builder.WriteString(string(info.Body))
} else {
builder.WriteString("<empty>")
}
@@ -152,7 +152,7 @@ func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt
if cfg == nil || !cfg.RequestLog {
return
}
data := bytes.TrimSpace(bytes.Clone(chunk))
data := bytes.TrimSpace(chunk)
if len(data) == 0 {
return
}

View File

@@ -88,12 +88,13 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
to = sdktranslator.FromString("openai-response")
endpoint = "/responses/compact"
}
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream)
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), opts.Stream)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream)
requestedModel := payloadRequestedModel(opts, req.Model)
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel)
if opts.Alt == "responses/compact" {
@@ -170,7 +171,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
reporter.ensurePublished(ctx)
// Translate response back to source format when needed
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, body, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -189,12 +190,13 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
requestedModel := payloadRequestedModel(opts, req.Model)
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel)
@@ -283,7 +285,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
// OpenAI-compatible streams are SSE: lines typically prefixed with "data: ".
// Pass through translator; it yields one or more chunks for the target schema.
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bytes.Clone(line), &param)
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
@@ -304,7 +306,7 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
modelForCounting := baseModel

View File

@@ -81,12 +81,13 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
@@ -150,7 +151,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
var param any
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
// the original model name in the response for client compatibility.
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, &param)
resp = cliproxyexecutor.Response{Payload: []byte(out)}
return resp, nil
}
@@ -171,12 +172,13 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
originalPayload := bytes.Clone(req.Payload)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = bytes.Clone(opts.OriginalRequest)
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
@@ -253,12 +255,12 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
if detail, ok := parseOpenAIStreamUsage(line); ok {
reporter.publish(ctx, detail)
}
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
}
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone([]byte("[DONE]")), &param)
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), &param)
for i := range doneChunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(doneChunks[i])}
}
@@ -276,7 +278,7 @@ func (e *QwenExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth,
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
modelName := gjson.GetBytes(body, "model").String()
if strings.TrimSpace(modelName) == "" {

View File

@@ -7,5 +7,6 @@ import (
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/iflow"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
)

View File

@@ -21,6 +21,9 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
// gcInterval defines minimum time between garbage collection runs.
const gcInterval = 5 * time.Minute
// GitTokenStore persists token records and auth metadata using git as the backing storage.
type GitTokenStore struct {
mu sync.Mutex
@@ -31,6 +34,7 @@ type GitTokenStore struct {
remote string
username string
password string
lastGC time.Time
}
// NewGitTokenStore creates a token store that saves credentials to disk through the
@@ -613,6 +617,7 @@ func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string)
} else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil {
return errRewrite
}
s.maybeRunGC(repo)
if err = repo.Push(&git.PushOptions{Auth: s.gitAuth(), Force: true}); err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
return nil
@@ -652,6 +657,23 @@ func (s *GitTokenStore) rewriteHeadAsSingleCommit(repo *git.Repository, branch p
return nil
}
func (s *GitTokenStore) maybeRunGC(repo *git.Repository) {
now := time.Now()
if now.Sub(s.lastGC) < gcInterval {
return
}
s.lastGC = now
pruneOpts := git.PruneOptions{
OnlyObjectsOlderThan: now,
Handler: repo.DeleteObject,
}
if err := repo.Prune(pruneOpts); err != nil && !errors.Is(err, git.ErrLooseObjectsNotSupported) {
return
}
_ = repo.RepackObjects(&git.RepackConfig{})
}
// PersistConfig commits and pushes configuration changes to git.
func (s *GitTokenStore) PersistConfig(_ context.Context) error {
if err := s.EnsureRepository(); err != nil {

View File

@@ -18,6 +18,7 @@ var providerAppliers = map[string]ProviderApplier{
"codex": nil,
"iflow": nil,
"antigravity": nil,
"kimi": nil,
}
// GetProviderApplier returns the ProviderApplier for the given provider name.
@@ -326,6 +327,9 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig {
return config
}
return extractOpenAIConfig(body)
case "kimi":
// Kimi uses OpenAI-compatible reasoning_effort format
return extractOpenAIConfig(body)
default:
return ThinkingConfig{}
}
@@ -388,7 +392,12 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
}
// Check thinkingLevel first (Gemini 3 format takes precedence)
if level := gjson.GetBytes(body, prefix+".thinkingLevel"); level.Exists() {
level := gjson.GetBytes(body, prefix+".thinkingLevel")
if !level.Exists() {
// Google official Gemini Python SDK sends snake_case field names
level = gjson.GetBytes(body, prefix+".thinking_level")
}
if level.Exists() {
value := level.String()
switch value {
case "none":
@@ -401,7 +410,12 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
}
// Check thinkingBudget (Gemini 2.5 format)
if budget := gjson.GetBytes(body, prefix+".thinkingBudget"); budget.Exists() {
budget := gjson.GetBytes(body, prefix+".thinkingBudget")
if !budget.Exists() {
// Google official Gemini Python SDK sends snake_case field names
budget = gjson.GetBytes(body, prefix+".thinking_budget")
}
if budget.Exists() {
value := int(budget.Int())
switch value {
case 0:

View File

@@ -94,8 +94,10 @@ func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig, m
}
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
@@ -114,28 +116,30 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig)
level := string(config.Level)
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true)
// Respect user's explicit includeThoughts setting from original body; default to true if not set
// Support both camelCase and snake_case variants
includeThoughts := true
if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
includeThoughts = inc.Bool()
} else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
includeThoughts = inc.Bool()
}
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
return result, nil
}
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) {
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
budget := config.Budget
includeThoughts := false
switch config.Mode {
case thinking.ModeNone:
includeThoughts = false
case thinking.ModeAuto:
includeThoughts = true
default:
includeThoughts = budget > 0
}
// Apply Claude-specific constraints
// Apply Claude-specific constraints first to get the final budget value
if isClaude && modelInfo != nil {
budget, result = a.normalizeClaudeBudget(budget, result, modelInfo)
// Check if budget was removed entirely
@@ -144,6 +148,37 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig,
}
}
// For ModeNone, always set includeThoughts to false regardless of user setting.
// This ensures that when user requests budget=0 (disable thinking output),
// the includeThoughts is correctly set to false even if budget is clamped to min.
if config.Mode == thinking.ModeNone {
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false)
return result, nil
}
// Determine includeThoughts: respect user's explicit setting from original body if provided
// Support both camelCase and snake_case variants
var includeThoughts bool
var userSetIncludeThoughts bool
if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
includeThoughts = inc.Bool()
userSetIncludeThoughts = true
} else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
includeThoughts = inc.Bool()
userSetIncludeThoughts = true
}
if !userSetIncludeThoughts {
// No explicit setting, use default logic based on mode
switch config.Mode {
case thinking.ModeAuto:
includeThoughts = true
default:
includeThoughts = budget > 0
}
}
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
return result, nil

View File

@@ -118,8 +118,10 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig)
// - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false)
// ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0.
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget")
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget")
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level")
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
@@ -138,29 +140,58 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig)
level := string(config.Level)
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level)
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true)
// Respect user's explicit includeThoughts setting from original body; default to true if not set
// Support both camelCase and snake_case variants
includeThoughts := true
if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
includeThoughts = inc.Bool()
} else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
includeThoughts = inc.Bool()
}
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts)
return result, nil
}
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel")
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level")
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget")
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
budget := config.Budget
// ModeNone semantics:
// - ModeNone + Budget=0: completely disable thinking
// - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false)
// When ZeroAllowed=false, ValidateConfig clamps Budget to Min while preserving ModeNone.
includeThoughts := false
switch config.Mode {
case thinking.ModeNone:
includeThoughts = false
case thinking.ModeAuto:
includeThoughts = true
default:
includeThoughts = budget > 0
// For ModeNone, always set includeThoughts to false regardless of user setting.
// This ensures that when user requests budget=0 (disable thinking output),
// the includeThoughts is correctly set to false even if budget is clamped to min.
if config.Mode == thinking.ModeNone {
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget)
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false)
return result, nil
}
// Determine includeThoughts: respect user's explicit setting from original body if provided
// Support both camelCase and snake_case variants
var includeThoughts bool
var userSetIncludeThoughts bool
if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
includeThoughts = inc.Bool()
userSetIncludeThoughts = true
} else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
includeThoughts = inc.Bool()
userSetIncludeThoughts = true
}
if !userSetIncludeThoughts {
// No explicit setting, use default logic based on mode
switch config.Mode {
case thinking.ModeAuto:
includeThoughts = true
default:
includeThoughts = budget > 0
}
}
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget)

View File

@@ -79,8 +79,10 @@ func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) (
}
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
@@ -99,25 +101,58 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig)
level := string(config.Level)
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true)
// Respect user's explicit includeThoughts setting from original body; default to true if not set
// Support both camelCase and snake_case variants
includeThoughts := true
if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
includeThoughts = inc.Bool()
} else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
includeThoughts = inc.Bool()
}
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
return result, nil
}
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
// Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
// Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing.
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
budget := config.Budget
includeThoughts := false
switch config.Mode {
case thinking.ModeNone:
includeThoughts = false
case thinking.ModeAuto:
includeThoughts = true
default:
includeThoughts = budget > 0
// For ModeNone, always set includeThoughts to false regardless of user setting.
// This ensures that when user requests budget=0 (disable thinking output),
// the includeThoughts is correctly set to false even if budget is clamped to min.
if config.Mode == thinking.ModeNone {
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false)
return result, nil
}
// Determine includeThoughts: respect user's explicit setting from original body if provided
// Support both camelCase and snake_case variants
var includeThoughts bool
var userSetIncludeThoughts bool
if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() {
includeThoughts = inc.Bool()
userSetIncludeThoughts = true
} else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() {
includeThoughts = inc.Bool()
userSetIncludeThoughts = true
}
if !userSetIncludeThoughts {
// No explicit setting, use default logic based on mode
switch config.Mode {
case thinking.ModeAuto:
includeThoughts = true
default:
includeThoughts = budget > 0
}
}
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)

View File

@@ -0,0 +1,126 @@
// Package kimi implements thinking configuration for Kimi (Moonshot AI) models.
//
// Kimi models use the OpenAI-compatible reasoning_effort format with discrete levels
// (low/medium/high). The provider strips any existing thinking config and applies
// the unified ThinkingConfig in OpenAI format.
package kimi
import (
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// Applier implements thinking.ProviderApplier for Kimi models.
//
// Kimi-specific behavior:
// - Output format: reasoning_effort (string: low/medium/high)
// - Uses OpenAI-compatible format
// - Supports budget-to-level conversion
type Applier struct{}
var _ thinking.ProviderApplier = (*Applier)(nil)
// NewApplier creates a new Kimi thinking applier.
func NewApplier() *Applier {
return &Applier{}
}
func init() {
thinking.RegisterProvider("kimi", NewApplier())
}
// Apply applies thinking configuration to Kimi 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 applyCompatibleKimi(body, config)
}
if modelInfo.Thinking == nil {
return body, nil
}
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:
// Kimi uses "none" to disable thinking
effort = string(thinking.LevelNone)
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
}
if effort == "" {
return body, nil
}
result, err := sjson.SetBytes(body, "reasoning_effort", effort)
if err != nil {
return body, fmt.Errorf("kimi thinking: failed to set reasoning_effort: %w", err)
}
return result, nil
}
// 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(`{}`)
}
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:
effort = string(thinking.LevelAuto)
case thinking.ModeBudget:
// Convert budget to level
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
if !ok {
return body, nil
}
effort = level
default:
return body, nil
}
result, err := sjson.SetBytes(body, "reasoning_effort", effort)
if err != nil {
return body, fmt.Errorf("kimi thinking: failed to set reasoning_effort: %w", err)
}
return result, nil
}

View File

@@ -6,7 +6,6 @@
package claude
import (
"bytes"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
@@ -37,7 +36,7 @@ import (
// - []byte: The transformed request data in Gemini CLI API format
func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
enableThoughtTranslate := true
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// system instruction
systemInstructionJSON := ""
@@ -115,7 +114,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if signatureResult.Exists() && signatureResult.String() != "" {
arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
if len(arrayClientSignatures) == 2 {
if modelName == arrayClientSignatures[0] {
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
clientSignature = arrayClientSignatures[1]
}
}

View File

@@ -6,7 +6,6 @@
package gemini
import (
"bytes"
"fmt"
"strings"
@@ -34,7 +33,7 @@ import (
// Returns:
// - []byte: The transformed request data in Gemini API format
func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
template := ""
template = `{"project":"","request":{},"model":""}`
template, _ = sjson.SetRaw(template, "request", string(rawJSON))

View File

@@ -3,7 +3,6 @@
package chat_completions
import (
"bytes"
"fmt"
"strings"
@@ -28,7 +27,7 @@ const geminiCLIFunctionThoughtSignature = "skip_thought_signature_validator"
// Returns:
// - []byte: The transformed request data in Gemini CLI API format
func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Base envelope (no default thinkingConfig)
out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`)

View File

@@ -1,14 +1,12 @@
package responses
import (
"bytes"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
)
func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
rawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream)
return ConvertGeminiRequestToAntigravity(modelName, rawJSON, stream)
}

View File

@@ -6,8 +6,6 @@
package geminiCLI
import (
"bytes"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -30,7 +28,7 @@ import (
// Returns:
// - []byte: The transformed request data in Claude Code API format
func ConvertGeminiCLIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
modelResult := gjson.GetBytes(rawJSON, "model")
// Extract the inner request object and promote it to the top level

View File

@@ -6,7 +6,6 @@
package gemini
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
@@ -46,7 +45,7 @@ var (
// Returns:
// - []byte: The transformed request data in Claude Code API format
func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
if account == "" {
u, _ := uuid.NewRandom()
@@ -116,7 +115,11 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
// Include thoughts configuration for reasoning process visibility
// Translator only does format conversion, ApplyThinking handles model capability validation.
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
thinkingLevel := thinkingConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = thinkingConfig.Get("thinking_level")
}
if thinkingLevel.Exists() {
level := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
switch level {
case "":
@@ -132,23 +135,29 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
}
} else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
budget := int(thinkingBudget.Int())
switch budget {
case 0:
out, _ = sjson.Set(out, "thinking.type", "disabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
case -1:
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
default:
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
} else {
thinkingBudget := thinkingConfig.Get("thinkingBudget")
if !thinkingBudget.Exists() {
thinkingBudget = thinkingConfig.Get("thinking_budget")
}
if thinkingBudget.Exists() {
budget := int(thinkingBudget.Int())
switch budget {
case 0:
out, _ = sjson.Set(out, "thinking.type", "disabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
case -1:
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
default:
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
} else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
out, _ = sjson.Set(out, "thinking.type", "enabled")
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
out, _ = sjson.Set(out, "thinking.type", "enabled")
}
} else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
out, _ = sjson.Set(out, "thinking.type", "enabled")
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
out, _ = sjson.Set(out, "thinking.type", "enabled")
}
}
}

View File

@@ -6,7 +6,6 @@
package chat_completions
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
@@ -44,7 +43,7 @@ var (
// Returns:
// - []byte: The transformed request data in Claude Code API format
func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
if account == "" {
u, _ := uuid.NewRandom()

View File

@@ -1,7 +1,6 @@
package responses
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
@@ -32,7 +31,7 @@ var (
// - max_output_tokens -> max_tokens
// - stream passthrough via parameter
func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
if account == "" {
u, _ := uuid.NewRandom()

View File

@@ -6,7 +6,6 @@
package claude
import (
"bytes"
"fmt"
"strconv"
"strings"
@@ -35,7 +34,7 @@ import (
// Returns:
// - []byte: The transformed request data in internal client format
func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
template := `{"model":"","instructions":"","input":[]}`

View File

@@ -113,10 +113,10 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
p := (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall
stopReason := rootResult.Get("response.stop_reason").String()
if stopReason != "" {
template, _ = sjson.Set(template, "delta.stop_reason", stopReason)
} else if p {
if p {
template, _ = sjson.Set(template, "delta.stop_reason", "tool_use")
} else if stopReason == "max_tokens" || stopReason == "stop" {
template, _ = sjson.Set(template, "delta.stop_reason", stopReason)
} else {
template, _ = sjson.Set(template, "delta.stop_reason", "end_turn")
}

View File

@@ -6,8 +6,6 @@
package geminiCLI
import (
"bytes"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -30,7 +28,7 @@ import (
// Returns:
// - []byte: The transformed request data in Codex API format
func ConvertGeminiCLIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
rawJSON = []byte(gjson.GetBytes(rawJSON, "request").Raw)
rawJSON, _ = sjson.SetBytes(rawJSON, "model", modelName)

View File

@@ -6,7 +6,6 @@
package gemini
import (
"bytes"
"crypto/rand"
"fmt"
"math/big"
@@ -37,7 +36,7 @@ import (
// Returns:
// - []byte: The transformed request data in Codex API format
func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Base template
out := `{"model":"","instructions":"","input":[]}`
@@ -243,19 +242,30 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
out, _ = sjson.Set(out, "parallel_tool_calls", true)
// 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 thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
thinkingLevel := thinkingConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = thinkingConfig.Get("thinking_level")
}
if thinkingLevel.Exists() {
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort != "" {
out, _ = sjson.Set(out, "reasoning.effort", effort)
effortSet = true
}
} else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
out, _ = sjson.Set(out, "reasoning.effort", effort)
effortSet = true
} else {
thinkingBudget := thinkingConfig.Get("thinkingBudget")
if !thinkingBudget.Exists() {
thinkingBudget = thinkingConfig.Get("thinking_budget")
}
if thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
out, _ = sjson.Set(out, "reasoning.effort", effort)
effortSet = true
}
}
}
}

View File

@@ -7,8 +7,6 @@
package chat_completions
import (
"bytes"
"strconv"
"strings"
@@ -29,7 +27,7 @@ import (
// Returns:
// - []byte: The transformed request data in OpenAI Responses API format
func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Start with empty JSON object
out := `{"instructions":""}`

View File

@@ -1,7 +1,6 @@
package responses
import (
"bytes"
"fmt"
"github.com/tidwall/gjson"
@@ -9,7 +8,13 @@ import (
)
func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
inputResult := gjson.GetBytes(rawJSON, "input")
if inputResult.Type == gjson.String {
input, _ := sjson.Set(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`, "0.content.0.text", inputResult.String())
rawJSON, _ = sjson.SetRawBytes(rawJSON, "input", []byte(input))
}
rawJSON, _ = sjson.SetBytes(rawJSON, "stream", true)
rawJSON, _ = sjson.SetBytes(rawJSON, "store", false)

View File

@@ -35,7 +35,7 @@ const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
// Returns:
// - []byte: The transformed request data in Gemini CLI API format
func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
rawJSON = bytes.Replace(rawJSON, []byte(`"url":{"type":"string","format":"uri",`), []byte(`"url":{"type":"string",`), -1)
// Build output Gemini CLI request JSON
@@ -116,6 +116,19 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
part, _ = sjson.Set(part, "functionResponse.name", funcName)
part, _ = sjson.Set(part, "functionResponse.response.result", responseData)
contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part)
case "image":
source := contentResult.Get("source")
if source.Get("type").String() == "base64" {
mimeType := source.Get("media_type").String()
data := source.Get("data").String()
if mimeType != "" && data != "" {
part := `{"inlineData":{"mime_type":"","data":""}}`
part, _ = sjson.Set(part, "inlineData.mime_type", mimeType)
part, _ = sjson.Set(part, "inlineData.data", data)
contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part)
}
}
}
return true
})

View File

@@ -6,7 +6,6 @@
package gemini
import (
"bytes"
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
@@ -33,7 +32,7 @@ import (
// Returns:
// - []byte: The transformed request data in Gemini API format
func ConvertGeminiRequestToGeminiCLI(_ string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
template := ""
template = `{"project":"","request":{},"model":""}`
template, _ = sjson.SetRaw(template, "request", string(rawJSON))

View File

@@ -3,7 +3,6 @@
package chat_completions
import (
"bytes"
"fmt"
"strings"
@@ -28,7 +27,7 @@ const geminiCLIFunctionThoughtSignature = "skip_thought_signature_validator"
// Returns:
// - []byte: The transformed request data in Gemini CLI API format
func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Base envelope (no default thinkingConfig)
out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`)

View File

@@ -14,6 +14,7 @@ import (
"time"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -77,14 +78,20 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
template, _ = sjson.Set(template, "id", responseIDResult.String())
}
// Extract and set the finish reason.
if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
template, _ = sjson.Set(template, "choices.0.finish_reason", strings.ToLower(finishReasonResult.String()))
template, _ = sjson.Set(template, "choices.0.native_finish_reason", strings.ToLower(finishReasonResult.String()))
finishReason := ""
if stopReasonResult := gjson.GetBytes(rawJSON, "response.stop_reason"); stopReasonResult.Exists() {
finishReason = stopReasonResult.String()
}
if finishReason == "" {
if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
finishReason = finishReasonResult.String()
}
}
finishReason = strings.ToLower(finishReason)
// Extract and set usage metadata (token counts).
if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() {
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
}
@@ -97,6 +104,14 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
if thoughtsTokenCount > 0 {
template, _ = sjson.Set(template, "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.Set(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
if err != nil {
log.Warnf("gemini-cli openai response: failed to set cached_tokens: %v", err)
}
}
}
// Process the main content part of the response.
@@ -187,6 +202,12 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
if hasFunctionCall {
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
} else if finishReason != "" && (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex == 0 {
// Only pass through specific finish reasons
if finishReason == "max_tokens" || finishReason == "stop" {
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReason)
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReason)
}
}
return []string{template}

View File

@@ -1,14 +1,12 @@
package responses
import (
"bytes"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/gemini"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
)
func ConvertOpenAIResponsesRequestToGeminiCLI(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
rawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream)
return ConvertGeminiRequestToGeminiCLI(modelName, rawJSON, stream)
}

View File

@@ -28,7 +28,7 @@ const geminiClaudeThoughtSignature = "skip_thought_signature_validator"
// Returns:
// - []byte: The transformed request in Gemini CLI format.
func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
rawJSON = bytes.Replace(rawJSON, []byte(`"url":{"type":"string","format":"uri",`), []byte(`"url":{"type":"string",`), -1)
// Build output Gemini CLI request JSON

View File

@@ -6,7 +6,6 @@
package geminiCLI
import (
"bytes"
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
@@ -19,7 +18,7 @@ import (
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the internal client.
func ConvertGeminiCLIRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
modelResult := gjson.GetBytes(rawJSON, "model")
rawJSON = []byte(gjson.GetBytes(rawJSON, "request").Raw)
rawJSON, _ = sjson.SetBytes(rawJSON, "model", modelResult.String())

View File

@@ -4,7 +4,6 @@
package gemini
import (
"bytes"
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
@@ -19,7 +18,7 @@ import (
//
// It keeps the payload otherwise unchanged.
func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Fast path: if no contents field, only attach safety settings
contents := gjson.GetBytes(rawJSON, "contents")
if !contents.Exists() {

View File

@@ -3,7 +3,6 @@
package chat_completions
import (
"bytes"
"fmt"
"strings"
@@ -28,7 +27,7 @@ const geminiFunctionThoughtSignature = "skip_thought_signature_validator"
// Returns:
// - []byte: The transformed request data in Gemini API format
func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Base envelope (no default thinkingConfig)
out := []byte(`{"contents":[]}`)

View File

@@ -129,11 +129,16 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
candidateIndex := int(candidate.Get("index").Int())
template, _ = sjson.Set(template, "choices.0.index", candidateIndex)
// Extract and set the finish reason.
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
template, _ = sjson.Set(template, "choices.0.finish_reason", strings.ToLower(finishReasonResult.String()))
template, _ = sjson.Set(template, "choices.0.native_finish_reason", strings.ToLower(finishReasonResult.String()))
finishReason := ""
if stopReasonResult := gjson.GetBytes(rawJSON, "stop_reason"); stopReasonResult.Exists() {
finishReason = stopReasonResult.String()
}
if finishReason == "" {
if finishReasonResult := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finishReasonResult.Exists() {
finishReason = finishReasonResult.String()
}
}
finishReason = strings.ToLower(finishReason)
partsResult := candidate.Get("content.parts")
hasFunctionCall := false
@@ -225,6 +230,12 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
if hasFunctionCall {
template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
} else if finishReason != "" {
// Only pass through specific finish reasons
if finishReason == "max_tokens" || finishReason == "stop" {
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReason)
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReason)
}
}
responseStrings = append(responseStrings, template)

View File

@@ -1,7 +1,6 @@
package responses
import (
"bytes"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
@@ -12,7 +11,7 @@ import (
const geminiResponsesThoughtSignature = "skip_thought_signature_validator"
func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Note: modelName and stream parameters are part of the fixed method signature
_ = modelName // Unused but required by interface

View File

@@ -17,7 +17,6 @@ import (
"github.com/tidwall/gjson"
)
// Kiro API request structs - field order determines JSON key order
// KiroPayload is the top-level request structure for Kiro API
@@ -34,7 +33,6 @@ type KiroInferenceConfig struct {
TopP float64 `json:"topP,omitempty"`
}
// KiroConversationState holds the conversation context
type KiroConversationState struct {
ChatTriggerType string `json:"chatTriggerType"` // Required: "MANUAL" - must be first field
@@ -117,9 +115,11 @@ type KiroAssistantResponseMessage struct {
// KiroToolUse represents a tool invocation by the assistant
type KiroToolUse struct {
ToolUseID string `json:"toolUseId"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
ToolUseID string `json:"toolUseId"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
IsTruncated bool `json:"-"` // Internal flag, not serialized
TruncationInfo *TruncationInfo `json:"-"` // Truncation details, not serialized
}
// ConvertClaudeRequestToKiro converts a Claude API request to Kiro format.
@@ -219,16 +219,35 @@ func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isA
}
// Convert Claude tools to Kiro format
kiroTools := convertClaudeToolsToKiro(tools)
kiroTools, hasWebSearch := convertClaudeToolsToKiro(tools)
// If web_search was requested but filtered, inject alternative hint
if hasWebSearch {
webSearchHint := `[CRITICAL WEB ACCESS INSTRUCTION]
You have the Fetch/read_url_content tool available. When the user asks about current events, weather, news, or any information that requires web access:
- DO NOT say you cannot search the web
- DO NOT refuse to help with web-related queries
- IMMEDIATELY use the Fetch tool to access relevant URLs
- Use well-known official websites, documentation sites, or API endpoints
- Construct appropriate URLs based on the query context
IMPORTANT: Always attempt to fetch information FIRST before declining. You CAN access the web via Fetch.`
if systemPrompt != "" {
systemPrompt += "\n" + webSearchHint
} else {
systemPrompt = webSearchHint
}
log.Infof("kiro: injected web_search alternative hint (tool was filtered)")
}
// Thinking mode implementation:
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
// When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
// rather than inline <thinking> tags in assistantResponseEvent.
// We use a high max_thinking_length to allow extensive reasoning.
// We cap max_thinking_length to reserve space for tool outputs and prevent truncation.
if thinkingEnabled {
thinkingHint := `<thinking_mode>enabled</thinking_mode>
<max_thinking_length>200000</max_thinking_length>`
<max_thinking_length>16000</max_thinking_length>`
if systemPrompt != "" {
systemPrompt = thinkingHint + "\n\n" + systemPrompt
} else {
@@ -378,7 +397,6 @@ func hasThinkingTagInBody(body []byte) bool {
return strings.Contains(bodyStr, "<thinking_mode>") || strings.Contains(bodyStr, "<max_thinking_length>")
}
// IsThinkingEnabledFromHeader checks if thinking mode is enabled via Anthropic-Beta header.
// Claude CLI uses "Anthropic-Beta: interleaved-thinking-2025-05-14" to enable thinking.
func IsThinkingEnabledFromHeader(headers http.Header) bool {
@@ -509,15 +527,27 @@ func ensureKiroInputSchema(parameters interface{}) interface{} {
}
}
// convertClaudeToolsToKiro converts Claude tools to Kiro format
func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
// convertClaudeToolsToKiro converts Claude tools to Kiro format.
// Returns the converted tools and a boolean indicating if web_search was filtered.
func convertClaudeToolsToKiro(tools gjson.Result) ([]KiroToolWrapper, bool) {
var kiroTools []KiroToolWrapper
hasWebSearch := false
if !tools.IsArray() {
return kiroTools
return kiroTools, hasWebSearch
}
for _, tool := range tools.Array() {
name := tool.Get("name").String()
// Filter out web_search/websearch tools (Kiro API doesn't support them)
// This matches the behavior in AIClient-2-API/claude-kiro.js
nameLower := strings.ToLower(name)
if nameLower == "web_search" || nameLower == "websearch" {
log.Debugf("kiro: skipping unsupported tool: %s", name)
hasWebSearch = true
continue
}
description := tool.Get("description").String()
inputSchemaResult := tool.Get("input_schema")
var inputSchema interface{}
@@ -561,7 +591,7 @@ func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
// This prevents 500 errors when Claude Code sends too many tools
kiroTools = compressToolsIfNeeded(kiroTools)
return kiroTools
return kiroTools, hasWebSearch
}
// processMessages processes Claude messages and builds Kiro history
@@ -578,18 +608,22 @@ func processMessages(messages gjson.Result, modelID, origin string) ([]KiroHisto
if role == "user" {
userMsg, toolResults := BuildUserMessageStruct(msg, modelID, origin)
// CRITICAL: Kiro API requires content to be non-empty for ALL user messages
// This includes both history messages and the current message.
// When user message contains only tool_result (no text), content will be empty.
// This commonly happens in compaction requests from OpenCode.
if strings.TrimSpace(userMsg.Content) == "" {
if len(toolResults) > 0 {
userMsg.Content = kirocommon.DefaultUserContentWithToolResults
} else {
userMsg.Content = kirocommon.DefaultUserContent
}
log.Debugf("kiro: user content was empty, using default: %s", userMsg.Content)
}
if isLastMessage {
currentUserMsg = &userMsg
currentToolResults = toolResults
} else {
// CRITICAL: Kiro API requires content to be non-empty for history messages too
if strings.TrimSpace(userMsg.Content) == "" {
if len(toolResults) > 0 {
userMsg.Content = "Tool results provided."
} else {
userMsg.Content = "Continue"
}
}
// For history messages, embed tool results in context
if len(toolResults) > 0 {
userMsg.UserInputMessageContext = &KiroUserInputMessageContext{
@@ -743,7 +777,35 @@ func BuildUserMessageStruct(msg gjson.Result, modelID, origin string) (KiroUserI
resultContent := part.Get("content")
var textContents []KiroTextContent
if resultContent.IsArray() {
// Check if this tool_result contains error from our SOFT_LIMIT_REACHED tool_use
// The client will return an error when trying to execute a tool with marker input
resultStr := resultContent.String()
isSoftLimitError := strings.Contains(resultStr, "SOFT_LIMIT_REACHED") ||
strings.Contains(resultStr, "_status") ||
strings.Contains(resultStr, "truncated") ||
strings.Contains(resultStr, "missing required") ||
strings.Contains(resultStr, "invalid input") ||
strings.Contains(resultStr, "Error writing file")
if isError && isSoftLimitError {
// Replace error content with SOFT_LIMIT_REACHED guidance
log.Infof("kiro: detected SOFT_LIMIT_REACHED in tool_result for %s, replacing with guidance", toolUseID)
softLimitMsg := `SOFT_LIMIT_REACHED
Your previous tool call was incomplete due to API output size limits.
The content was PARTIALLY transmitted but NOT executed.
REQUIRED ACTION:
1. Split your content into smaller chunks (max 300 lines per call)
2. For file writes: Create file with first chunk, then use append for remaining
3. Do NOT regenerate content you already attempted - continue from where you stopped
STATUS: This is NOT an error. Continue with smaller chunks.`
textContents = append(textContents, KiroTextContent{Text: softLimitMsg})
// Mark as SUCCESS so Claude doesn't treat it as a failure
isError = false
} else if resultContent.IsArray() {
for _, item := range resultContent.Array() {
if item.Get("type").String() == "text" {
textContents = append(textContents, KiroTextContent{Text: item.Get("text").String()})
@@ -825,8 +887,21 @@ func BuildAssistantMessageStruct(msg gjson.Result) KiroAssistantResponseMessage
contentBuilder.WriteString(content.String())
}
// CRITICAL FIX: Kiro API requires non-empty content for assistant messages
// This can happen with compaction requests where assistant messages have only tool_use
// (no text content). Without this fix, Kiro API returns "Improperly formed request" error.
finalContent := contentBuilder.String()
if strings.TrimSpace(finalContent) == "" {
if len(toolUses) > 0 {
finalContent = kirocommon.DefaultAssistantContentWithTools
} else {
finalContent = kirocommon.DefaultAssistantContent
}
log.Debugf("kiro: assistant content was empty, using default: %s", finalContent)
}
return KiroAssistantResponseMessage{
Content: contentBuilder.String(),
Content: finalContent,
ToolUses: toolUses,
}
}

View File

@@ -55,14 +55,39 @@ func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, u
}
}
// Add tool_use blocks
// Add tool_use blocks - emit truncated tools with SOFT_LIMIT_REACHED marker
hasTruncatedTools := false
for _, toolUse := range toolUses {
contentBlocks = append(contentBlocks, map[string]interface{}{
"type": "tool_use",
"id": toolUse.ToolUseID,
"name": toolUse.Name,
"input": toolUse.Input,
})
if toolUse.IsTruncated && toolUse.TruncationInfo != nil {
// Emit tool_use with SOFT_LIMIT_REACHED marker input
hasTruncatedTools = true
log.Infof("kiro: buildClaudeResponse emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", toolUse.Name, toolUse.ToolUseID)
markerInput := map[string]interface{}{
"_status": "SOFT_LIMIT_REACHED",
"_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.",
}
contentBlocks = append(contentBlocks, map[string]interface{}{
"type": "tool_use",
"id": toolUse.ToolUseID,
"name": toolUse.Name,
"input": markerInput,
})
} else {
// Normal tool use
contentBlocks = append(contentBlocks, map[string]interface{}{
"type": "tool_use",
"id": toolUse.ToolUseID,
"name": toolUse.Name,
"input": toolUse.Input,
})
}
}
// Log if we used SOFT_LIMIT_REACHED
if hasTruncatedTools {
log.Infof("kiro: buildClaudeResponse using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use")
}
// Ensure at least one content block (Claude API requires non-empty content)
@@ -74,6 +99,7 @@ func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, u
}
// Use upstream stopReason; apply fallback logic if not provided
// SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop
if stopReason == "" {
stopReason = "end_turn"
if len(toolUses) > 0 {
@@ -201,4 +227,4 @@ func ExtractThinkingFromContent(content string) []map[string]interface{} {
}
return blocks
}
}

View File

@@ -14,10 +14,11 @@ import (
// ToolUseState tracks the state of an in-progress tool use during streaming.
type ToolUseState struct {
ToolUseID string
Name string
InputBuffer strings.Builder
IsComplete bool
ToolUseID string
Name string
InputBuffer strings.Builder
IsComplete bool
TruncationInfo *TruncationInfo // Truncation detection result (set when complete)
}
// Pre-compiled regex patterns for performance
@@ -395,17 +396,6 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt
isStop = stop
}
// Debug: log when stop event arrives
if isStop {
log.Debugf("kiro: toolUseEvent stop=true received for tool %s (ID: %s), currentToolUse buffer len: %d",
toolName, toolUseID, func() int {
if currentToolUse != nil {
return currentToolUse.InputBuffer.Len()
}
return -1
}())
}
// Get input - can be string (fragment) or object (complete)
var inputFragment string
var inputMap map[string]interface{}
@@ -477,98 +467,39 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt
if isStop && currentToolUse != nil {
fullInput := currentToolUse.InputBuffer.String()
// Check for Write tool with empty or missing input - this happens when Kiro API
// completely skips sending input for large file writes
if currentToolUse.Name == "Write" && len(strings.TrimSpace(fullInput)) == 0 {
log.Warnf("kiro: Write tool received no input from upstream API. The file content may be too large to transmit.")
// Return nil to skip this tool use - it will be handled as a truncation error
// The caller should emit a text block explaining the error instead
if processedIDs != nil {
processedIDs[currentToolUse.ToolUseID] = true
}
log.Infof("kiro: skipping Write tool use %s due to empty input (content too large)", currentToolUse.ToolUseID)
// Return a special marker tool use that indicates truncation
toolUse := KiroToolUse{
ToolUseID: currentToolUse.ToolUseID,
Name: "__truncated_write__", // Special marker name
Input: map[string]interface{}{
"error": "Write tool input was not transmitted by upstream API. The file content is too large.",
},
}
toolUses = append(toolUses, toolUse)
return toolUses, nil
}
// Repair and parse the accumulated JSON
repairedJSON := RepairJSON(fullInput)
var finalInput map[string]interface{}
if err := json.Unmarshal([]byte(repairedJSON), &finalInput); err != nil {
log.Warnf("kiro: failed to parse accumulated tool input: %v, raw: %s", err, fullInput)
finalInput = make(map[string]interface{})
// Check if this is a Write tool with truncated input (missing content field)
// This happens when the Kiro API truncates large tool inputs
if currentToolUse.Name == "Write" && strings.Contains(fullInput, "file_path") && !strings.Contains(fullInput, "content") {
log.Warnf("kiro: Write tool input was truncated by upstream API (content field missing). The file content may be too large.")
// Extract file_path if possible for error context
filePath := ""
if idx := strings.Index(fullInput, "file_path"); idx >= 0 {
// Try to extract the file path value
rest := fullInput[idx:]
if colonIdx := strings.Index(rest, ":"); colonIdx >= 0 {
rest = strings.TrimSpace(rest[colonIdx+1:])
if len(rest) > 0 && rest[0] == '"' {
rest = rest[1:]
if endQuote := strings.Index(rest, "\""); endQuote >= 0 {
filePath = rest[:endQuote]
}
}
}
}
if processedIDs != nil {
processedIDs[currentToolUse.ToolUseID] = true
}
// Return a special marker tool use that indicates truncation
toolUse := KiroToolUse{
ToolUseID: currentToolUse.ToolUseID,
Name: "__truncated_write__", // Special marker name
Input: map[string]interface{}{
"error": "Write tool content was truncated by upstream API. The file content is too large.",
"file_path": filePath,
},
}
toolUses = append(toolUses, toolUse)
return toolUses, nil
}
}
// Additional check: Write tool parsed successfully but missing content field
if currentToolUse.Name == "Write" {
if _, hasContent := finalInput["content"]; !hasContent {
if filePath, hasPath := finalInput["file_path"]; hasPath {
log.Warnf("kiro: Write tool input missing 'content' field, likely truncated by upstream API")
if processedIDs != nil {
processedIDs[currentToolUse.ToolUseID] = true
}
// Return a special marker tool use that indicates truncation
toolUse := KiroToolUse{
ToolUseID: currentToolUse.ToolUseID,
Name: "__truncated_write__", // Special marker name
Input: map[string]interface{}{
"error": "Write tool content field was missing. The file content is too large.",
"file_path": filePath,
},
}
toolUses = append(toolUses, toolUse)
return toolUses, nil
}
// Detect truncation for all tools
truncInfo := DetectTruncation(currentToolUse.Name, currentToolUse.ToolUseID, fullInput, finalInput)
if truncInfo.IsTruncated {
log.Warnf("kiro: TRUNCATION DETECTED for tool %s (ID: %s): type=%s, raw_size=%d bytes",
currentToolUse.Name, currentToolUse.ToolUseID, truncInfo.TruncationType, len(fullInput))
log.Warnf("kiro: truncation details: %s", truncInfo.ErrorMessage)
if len(truncInfo.ParsedFields) > 0 {
log.Infof("kiro: partial fields received: %v", truncInfo.ParsedFields)
}
// Store truncation info in the state for upstream handling
currentToolUse.TruncationInfo = &truncInfo
} else {
log.Infof("kiro: tool use %s input length: %d bytes (no truncation)", currentToolUse.Name, len(fullInput))
}
// Create the tool use with truncation info if applicable
toolUse := KiroToolUse{
ToolUseID: currentToolUse.ToolUseID,
Name: currentToolUse.Name,
Input: finalInput,
ToolUseID: currentToolUse.ToolUseID,
Name: currentToolUse.Name,
Input: finalInput,
IsTruncated: truncInfo.IsTruncated,
TruncationInfo: nil, // Will be set below if truncated
}
if truncInfo.IsTruncated {
toolUse.TruncationInfo = &truncInfo
}
toolUses = append(toolUses, toolUse)
@@ -576,7 +507,7 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt
processedIDs[currentToolUse.ToolUseID] = true
}
log.Infof("kiro: completed tool use: %s (ID: %s)", currentToolUse.Name, currentToolUse.ToolUseID)
log.Infof("kiro: completed tool use: %s (ID: %s, truncated: %v)", currentToolUse.Name, currentToolUse.ToolUseID, truncInfo.IsTruncated)
return toolUses, nil
}
@@ -610,4 +541,3 @@ func DeduplicateToolUses(toolUses []KiroToolUse) []KiroToolUse {
return unique
}

View File

@@ -0,0 +1,517 @@
// Package claude provides truncation detection for Kiro tool call responses.
// When Kiro API reaches its output token limit, tool call JSON may be truncated,
// resulting in incomplete or unparseable input parameters.
package claude
import (
"encoding/json"
"strings"
log "github.com/sirupsen/logrus"
)
// TruncationInfo contains details about detected truncation in a tool use event.
type TruncationInfo struct {
IsTruncated bool // Whether truncation was detected
TruncationType string // Type of truncation detected
ToolName string // Name of the truncated tool
ToolUseID string // ID of the truncated tool use
RawInput string // The raw (possibly truncated) input string
ParsedFields map[string]string // Fields that were successfully parsed before truncation
ErrorMessage string // Human-readable error message
}
// TruncationType constants for different truncation scenarios
const (
TruncationTypeNone = "" // No truncation detected
TruncationTypeEmptyInput = "empty_input" // No input data received at all
TruncationTypeInvalidJSON = "invalid_json" // JSON is syntactically invalid (truncated mid-value)
TruncationTypeMissingFields = "missing_fields" // JSON parsed but critical fields are missing
TruncationTypeIncompleteString = "incomplete_string" // String value was cut off mid-content
)
// KnownWriteTools lists tool names that typically write content and have a "content" field.
// These tools are checked for content field truncation specifically.
var KnownWriteTools = map[string]bool{
"Write": true,
"write_to_file": true,
"fsWrite": true,
"create_file": true,
"edit_file": true,
"apply_diff": true,
"str_replace_editor": true,
"insert": true,
}
// KnownCommandTools lists tool names that execute commands.
var KnownCommandTools = map[string]bool{
"Bash": true,
"execute": true,
"run_command": true,
"shell": true,
"terminal": true,
"execute_python": true,
}
// RequiredFieldsByTool maps tool names to their required fields.
// If any of these fields are missing, the tool input is considered truncated.
var RequiredFieldsByTool = map[string][]string{
"Write": {"file_path", "content"},
"write_to_file": {"path", "content"},
"fsWrite": {"path", "content"},
"create_file": {"path", "content"},
"edit_file": {"path"},
"apply_diff": {"path", "diff"},
"str_replace_editor": {"path", "old_str", "new_str"},
"Bash": {"command"},
"execute": {"command"},
"run_command": {"command"},
}
// DetectTruncation checks if the tool use input appears to be truncated.
// It returns detailed information about the truncation status and type.
func DetectTruncation(toolName, toolUseID, rawInput string, parsedInput map[string]interface{}) TruncationInfo {
info := TruncationInfo{
ToolName: toolName,
ToolUseID: toolUseID,
RawInput: rawInput,
ParsedFields: make(map[string]string),
}
// Scenario 1: Empty input buffer - no data received at all
if strings.TrimSpace(rawInput) == "" {
info.IsTruncated = true
info.TruncationType = TruncationTypeEmptyInput
info.ErrorMessage = "Tool input was completely empty - API response may have been truncated before tool parameters were transmitted"
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): empty input buffer",
info.TruncationType, toolName, toolUseID)
return info
}
// Scenario 2: JSON parse failure - syntactically invalid JSON
if parsedInput == nil || len(parsedInput) == 0 {
// Check if the raw input looks like truncated JSON
if looksLikeTruncatedJSON(rawInput) {
info.IsTruncated = true
info.TruncationType = TruncationTypeInvalidJSON
info.ParsedFields = extractPartialFields(rawInput)
info.ErrorMessage = buildTruncationErrorMessage(toolName, info.TruncationType, info.ParsedFields, rawInput)
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): JSON parse failed, raw length=%d bytes",
info.TruncationType, toolName, toolUseID, len(rawInput))
return info
}
}
// Scenario 3: JSON parsed but critical fields are missing
if parsedInput != nil {
requiredFields, hasRequirements := RequiredFieldsByTool[toolName]
if hasRequirements {
missingFields := findMissingRequiredFields(parsedInput, requiredFields)
if len(missingFields) > 0 {
info.IsTruncated = true
info.TruncationType = TruncationTypeMissingFields
info.ParsedFields = extractParsedFieldNames(parsedInput)
info.ErrorMessage = buildMissingFieldsErrorMessage(toolName, missingFields, info.ParsedFields)
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): missing required fields: %v",
info.TruncationType, toolName, toolUseID, missingFields)
return info
}
}
// Scenario 4: Check for incomplete string values (very short content for write tools)
if isWriteTool(toolName) {
if contentTruncation := detectContentTruncation(parsedInput, rawInput); contentTruncation != "" {
info.IsTruncated = true
info.TruncationType = TruncationTypeIncompleteString
info.ParsedFields = extractParsedFieldNames(parsedInput)
info.ErrorMessage = contentTruncation
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): %s",
info.TruncationType, toolName, toolUseID, contentTruncation)
return info
}
}
}
// No truncation detected
info.IsTruncated = false
info.TruncationType = TruncationTypeNone
return info
}
// looksLikeTruncatedJSON checks if the raw string appears to be truncated JSON.
func looksLikeTruncatedJSON(raw string) bool {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return false
}
// Must start with { to be considered JSON
if !strings.HasPrefix(trimmed, "{") {
return false
}
// Count brackets to detect imbalance
openBraces := strings.Count(trimmed, "{")
closeBraces := strings.Count(trimmed, "}")
openBrackets := strings.Count(trimmed, "[")
closeBrackets := strings.Count(trimmed, "]")
// Bracket imbalance suggests truncation
if openBraces > closeBraces || openBrackets > closeBrackets {
return true
}
// Check for obvious truncation patterns
// - Ends with a quote but no closing brace
// - Ends with a colon (mid key-value)
// - Ends with a comma (mid object/array)
lastChar := trimmed[len(trimmed)-1]
if lastChar != '}' && lastChar != ']' {
// Check if it's not a complete simple value
if lastChar == '"' || lastChar == ':' || lastChar == ',' {
return true
}
}
// Check for unclosed strings (odd number of unescaped quotes)
inString := false
escaped := false
for i := 0; i < len(trimmed); i++ {
c := trimmed[i]
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = !inString
}
}
if inString {
return true // Unclosed string
}
return false
}
// extractPartialFields attempts to extract any field names from malformed JSON.
// This helps provide context about what was received before truncation.
func extractPartialFields(raw string) map[string]string {
fields := make(map[string]string)
// Simple pattern matching for "key": "value" or "key": value patterns
// This works even with truncated JSON
trimmed := strings.TrimSpace(raw)
if !strings.HasPrefix(trimmed, "{") {
return fields
}
// Remove opening brace
content := strings.TrimPrefix(trimmed, "{")
// Split by comma (rough parsing)
parts := strings.Split(content, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if colonIdx := strings.Index(part, ":"); colonIdx > 0 {
key := strings.TrimSpace(part[:colonIdx])
key = strings.Trim(key, `"`)
value := strings.TrimSpace(part[colonIdx+1:])
// Truncate long values for display
if len(value) > 50 {
value = value[:50] + "..."
}
fields[key] = value
}
}
return fields
}
// extractParsedFieldNames returns the field names from a successfully parsed map.
func extractParsedFieldNames(parsed map[string]interface{}) map[string]string {
fields := make(map[string]string)
for key, val := range parsed {
switch v := val.(type) {
case string:
if len(v) > 50 {
fields[key] = v[:50] + "..."
} else {
fields[key] = v
}
case nil:
fields[key] = "<null>"
default:
// For complex types, just indicate presence
fields[key] = "<present>"
}
}
return fields
}
// findMissingRequiredFields checks which required fields are missing from the parsed input.
func findMissingRequiredFields(parsed map[string]interface{}, required []string) []string {
var missing []string
for _, field := range required {
if _, exists := parsed[field]; !exists {
missing = append(missing, field)
}
}
return missing
}
// isWriteTool checks if the tool is a known write/file operation tool.
func isWriteTool(toolName string) bool {
return KnownWriteTools[toolName]
}
// detectContentTruncation checks if the content field appears truncated for write tools.
func detectContentTruncation(parsed map[string]interface{}, rawInput string) string {
// Check for content field
content, hasContent := parsed["content"]
if !hasContent {
return ""
}
contentStr, isString := content.(string)
if !isString {
return ""
}
// Heuristic: if raw input is very large but content is suspiciously short,
// it might indicate truncation during JSON repair
if len(rawInput) > 1000 && len(contentStr) < 100 {
return "content field appears suspiciously short compared to raw input size"
}
// Check for code blocks that appear to be cut off
if strings.Contains(contentStr, "```") {
openFences := strings.Count(contentStr, "```")
if openFences%2 != 0 {
return "content contains unclosed code fence (```) suggesting truncation"
}
}
return ""
}
// buildTruncationErrorMessage creates a human-readable error message for truncation.
func buildTruncationErrorMessage(toolName, truncationType string, parsedFields map[string]string, rawInput string) string {
var sb strings.Builder
sb.WriteString("Tool input was truncated by the API. ")
switch truncationType {
case TruncationTypeEmptyInput:
sb.WriteString("No input data was received.")
case TruncationTypeInvalidJSON:
sb.WriteString("JSON was cut off mid-transmission. ")
if len(parsedFields) > 0 {
sb.WriteString("Partial fields received: ")
first := true
for k := range parsedFields {
if !first {
sb.WriteString(", ")
}
sb.WriteString(k)
first = false
}
}
case TruncationTypeMissingFields:
sb.WriteString("Required fields are missing from the input.")
case TruncationTypeIncompleteString:
sb.WriteString("Content appears to be shortened or incomplete.")
}
sb.WriteString(" Received ")
sb.WriteString(string(rune(len(rawInput))))
sb.WriteString(" bytes. Please retry with smaller content chunks.")
return sb.String()
}
// buildMissingFieldsErrorMessage creates an error message for missing required fields.
func buildMissingFieldsErrorMessage(toolName string, missingFields []string, parsedFields map[string]string) string {
var sb strings.Builder
sb.WriteString("Tool '")
sb.WriteString(toolName)
sb.WriteString("' is missing required fields: ")
sb.WriteString(strings.Join(missingFields, ", "))
sb.WriteString(". Fields received: ")
first := true
for k := range parsedFields {
if !first {
sb.WriteString(", ")
}
sb.WriteString(k)
first = false
}
sb.WriteString(". This usually indicates the API response was truncated.")
return sb.String()
}
// IsTruncated is a convenience function to check if a tool use appears truncated.
func IsTruncated(toolName, rawInput string, parsedInput map[string]interface{}) bool {
info := DetectTruncation(toolName, "", rawInput, parsedInput)
return info.IsTruncated
}
// GetTruncationSummary returns a short summary string for logging.
func GetTruncationSummary(info TruncationInfo) string {
if !info.IsTruncated {
return ""
}
result, _ := json.Marshal(map[string]interface{}{
"tool": info.ToolName,
"type": info.TruncationType,
"parsed_fields": info.ParsedFields,
"raw_input_size": len(info.RawInput),
})
return string(result)
}
// SoftFailureMessage contains the message structure for a truncation soft failure.
// This is returned to Claude as a tool_result to guide retry behavior.
type SoftFailureMessage struct {
Status string // "incomplete" - not an error, just incomplete
Reason string // Why the tool call was incomplete
Guidance []string // Step-by-step retry instructions
Context string // Any context about what was received
MaxLineHint int // Suggested maximum lines per chunk
}
// BuildSoftFailureMessage creates a structured message for Claude when truncation is detected.
// This follows the "soft failure" pattern:
// - For Claude: Clear explanation of what happened and how to fix
// - For User: Hidden or minimized (appears as normal processing)
//
// Key principle: "Conclusion First"
// 1. First state what happened (incomplete)
// 2. Then explain how to fix (chunked approach)
// 3. Provide specific guidance (line limits)
func BuildSoftFailureMessage(info TruncationInfo) SoftFailureMessage {
msg := SoftFailureMessage{
Status: "incomplete",
MaxLineHint: 300, // Conservative default
}
// Build reason based on truncation type
switch info.TruncationType {
case TruncationTypeEmptyInput:
msg.Reason = "Your tool call was too large and the input was completely lost during transmission."
msg.MaxLineHint = 200
case TruncationTypeInvalidJSON:
msg.Reason = "Your tool call was truncated mid-transmission, resulting in incomplete JSON."
msg.MaxLineHint = 250
case TruncationTypeMissingFields:
msg.Reason = "Your tool call was partially received but critical fields were cut off."
msg.MaxLineHint = 300
case TruncationTypeIncompleteString:
msg.Reason = "Your tool call content was truncated - the full content did not arrive."
msg.MaxLineHint = 350
default:
msg.Reason = "Your tool call was truncated by the API due to output size limits."
}
// Build context from parsed fields
if len(info.ParsedFields) > 0 {
var parts []string
for k, v := range info.ParsedFields {
if len(v) > 30 {
v = v[:30] + "..."
}
parts = append(parts, k+"="+v)
}
msg.Context = "Received partial data: " + strings.Join(parts, ", ")
}
// Build retry guidance - CRITICAL: Conclusion first approach
msg.Guidance = []string{
"CONCLUSION: Split your output into smaller chunks and retry.",
"",
"REQUIRED APPROACH:",
"1. For file writes: Write in chunks of ~" + formatInt(msg.MaxLineHint) + " lines maximum",
"2. For new files: First create with initial chunk, then append remaining sections",
"3. For edits: Make surgical, targeted changes - avoid rewriting entire files",
"",
"EXAMPLE (writing a 600-line file):",
" - Step 1: Write lines 1-300 (create file)",
" - Step 2: Append lines 301-600 (extend file)",
"",
"DO NOT attempt to write the full content again in a single call.",
"The API has a hard output limit that cannot be bypassed.",
}
return msg
}
// formatInt converts an integer to string (helper to avoid strconv import)
func formatInt(n int) string {
if n == 0 {
return "0"
}
result := ""
for n > 0 {
result = string(rune('0'+n%10)) + result
n /= 10
}
return result
}
// BuildSoftFailureToolResult creates a tool_result content for Claude.
// This is what Claude will see when a tool call is truncated.
// Returns a string that should be used as the tool_result content.
func BuildSoftFailureToolResult(info TruncationInfo) string {
msg := BuildSoftFailureMessage(info)
var sb strings.Builder
sb.WriteString("TOOL_CALL_INCOMPLETE\n")
sb.WriteString("status: ")
sb.WriteString(msg.Status)
sb.WriteString("\n")
sb.WriteString("reason: ")
sb.WriteString(msg.Reason)
sb.WriteString("\n")
if msg.Context != "" {
sb.WriteString("context: ")
sb.WriteString(msg.Context)
sb.WriteString("\n")
}
sb.WriteString("\n")
for _, line := range msg.Guidance {
if line != "" {
sb.WriteString(line)
sb.WriteString("\n")
}
}
return sb.String()
}
// CreateTruncationToolResult creates a KiroToolUse that represents a soft failure.
// Instead of returning the truncated tool_use, we return a tool with a special
// error result that guides Claude to retry with smaller chunks.
//
// This is the key mechanism for "soft failure":
// - stop_reason remains "tool_use" so Claude continues
// - The tool_result content explains the issue and how to fix it
// - Claude will read this and adjust its approach
func CreateTruncationToolResult(info TruncationInfo) KiroToolUse {
// We create a pseudo tool_use that represents the failed attempt
// The executor will convert this to a tool_result with the guidance message
return KiroToolUse{
ToolUseID: info.ToolUseID,
Name: info.ToolName,
Input: nil, // No input since it was truncated
IsTruncated: true,
TruncationInfo: &info,
}
}

View File

@@ -29,6 +29,26 @@ const (
// InlineCodeMarker is the markdown inline code marker (backtick).
InlineCodeMarker = "`"
// DefaultAssistantContentWithTools is the fallback content for assistant messages
// that have tool_use but no text content. Kiro API requires non-empty content.
// IMPORTANT: Use a minimal neutral string that the model won't mimic in responses.
// Previously "I'll help you with that." which caused the model to parrot it back.
DefaultAssistantContentWithTools = "."
// DefaultAssistantContent is the fallback content for assistant messages
// that have no content at all. Kiro API requires non-empty content.
// IMPORTANT: Use a minimal neutral string that the model won't mimic in responses.
// Previously "I understand." which could leak into model behavior.
DefaultAssistantContent = "."
// DefaultUserContentWithToolResults is the fallback content for user messages
// that have only tool_result (no text). Kiro API requires non-empty content.
DefaultUserContentWithToolResults = "Tool results provided."
// DefaultUserContent is the fallback content for user messages
// that have no content at all. Kiro API requires non-empty content.
DefaultUserContent = "Continue"
// KiroAgenticSystemPrompt is injected only for -agentic models to prevent timeouts on large writes.
// AWS Kiro API has a 2-3 minute timeout for large file write operations.
KiroAgenticSystemPrompt = `

View File

@@ -576,9 +576,23 @@ func processOpenAIMessages(messages gjson.Result, modelID, origin string) ([]Kir
}
}
// Truncate history if too long to prevent Kiro API errors
history = truncateHistoryIfNeeded(history)
return history, currentUserMsg, currentToolResults
}
const kiroMaxHistoryMessages = 50
func truncateHistoryIfNeeded(history []KiroHistoryMessage) []KiroHistoryMessage {
if len(history) <= kiroMaxHistoryMessages {
return history
}
log.Debugf("kiro-openai: truncating history from %d to %d messages", len(history), kiroMaxHistoryMessages)
return history[len(history)-kiroMaxHistoryMessages:]
}
// buildUserMessageFromOpenAI builds a user message from OpenAI format and extracts tool results
func buildUserMessageFromOpenAI(msg gjson.Result, modelID, origin string) (KiroUserInputMessage, []KiroToolResult) {
content := msg.Get("content")
@@ -645,13 +659,36 @@ func buildAssistantMessageFromOpenAI(msg gjson.Result) KiroAssistantResponseMess
contentBuilder.WriteString(content.String())
} else if content.IsArray() {
for _, part := range content.Array() {
if part.Get("type").String() == "text" {
partType := part.Get("type").String()
switch partType {
case "text":
contentBuilder.WriteString(part.Get("text").String())
case "tool_use":
// Handle tool_use in content array (Anthropic/OpenCode format)
// This is different from OpenAI's tool_calls format
toolUseID := part.Get("id").String()
toolName := part.Get("name").String()
inputData := part.Get("input")
inputMap := make(map[string]interface{})
if inputData.Exists() && inputData.IsObject() {
inputData.ForEach(func(key, value gjson.Result) bool {
inputMap[key.String()] = value.Value()
return true
})
}
toolUses = append(toolUses, KiroToolUse{
ToolUseID: toolUseID,
Name: toolName,
Input: inputMap,
})
log.Debugf("kiro-openai: extracted tool_use from content array: %s", toolName)
}
}
}
// Handle tool_calls
// Handle tool_calls (OpenAI format)
toolCalls := msg.Get("tool_calls")
if toolCalls.IsArray() {
for _, tc := range toolCalls.Array() {
@@ -677,8 +714,20 @@ func buildAssistantMessageFromOpenAI(msg gjson.Result) KiroAssistantResponseMess
}
}
// CRITICAL FIX: Kiro API requires non-empty content for assistant messages
// This can happen with compaction requests or error recovery scenarios
finalContent := contentBuilder.String()
if strings.TrimSpace(finalContent) == "" {
if len(toolUses) > 0 {
finalContent = kirocommon.DefaultAssistantContentWithTools
} else {
finalContent = kirocommon.DefaultAssistantContent
}
log.Debugf("kiro-openai: assistant content was empty, using default: %s", finalContent)
}
return KiroAssistantResponseMessage{
Content: contentBuilder.String(),
Content: finalContent,
ToolUses: toolUses,
}
}

View File

@@ -6,7 +6,6 @@
package claude
import (
"bytes"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
@@ -18,7 +17,7 @@ import (
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the OpenAI API.
func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Base OpenAI Chat Completions API template
out := `{"model":"","messages":[]}`

View File

@@ -6,8 +6,6 @@
package geminiCLI
import (
"bytes"
. "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -17,7 +15,7 @@ import (
// It extracts the model name, generation config, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the OpenAI API.
func ConvertGeminiCLIRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
rawJSON = []byte(gjson.GetBytes(rawJSON, "request").Raw)
rawJSON, _ = sjson.SetBytes(rawJSON, "model", modelName)
if gjson.GetBytes(rawJSON, "systemInstruction").Exists() {

View File

@@ -6,7 +6,6 @@
package gemini
import (
"bytes"
"crypto/rand"
"fmt"
"math/big"
@@ -21,7 +20,7 @@ import (
// It extracts the model name, generation config, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the OpenAI API.
func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Base OpenAI Chat Completions API template
out := `{"model":"","messages":[]}`
@@ -83,16 +82,27 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
// Map Gemini thinkingConfig to OpenAI reasoning_effort.
// Always perform conversion to support allowCompat models that may not be in registry
// Always perform conversion to support allowCompat models that may not be in registry.
// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
thinkingLevel := thinkingConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = thinkingConfig.Get("thinking_level")
}
if thinkingLevel.Exists() {
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
} else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
out, _ = sjson.Set(out, "reasoning_effort", effort)
} else {
thinkingBudget := thinkingConfig.Get("thinkingBudget")
if !thinkingBudget.Exists() {
thinkingBudget = thinkingConfig.Get("thinking_budget")
}
if thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
}
}
}

View File

@@ -3,7 +3,6 @@
package chat_completions
import (
"bytes"
"github.com/tidwall/sjson"
)
@@ -25,7 +24,7 @@ func ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool)
// 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
// handling mechanism would be needed.
return bytes.Clone(inputRawJSON)
return inputRawJSON
}
return updatedJSON
}

View File

@@ -1,7 +1,6 @@
package responses
import (
"bytes"
"strings"
"github.com/tidwall/gjson"
@@ -28,7 +27,7 @@ import (
// Returns:
// - []byte: The transformed request data in OpenAI chat completions format
func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
rawJSON := inputRawJSON
// Base OpenAI chat completions template with default values
out := `{"model":"","messages":[],"stream":false}`
@@ -68,7 +67,10 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
case "message", "":
// Handle regular message conversion
role := item.Get("role").String()
message := `{"role":"","content":""}`
if role == "developer" {
role = "user"
}
message := `{"role":"","content":[]}`
message, _ = sjson.Set(message, "role", role)
if content := item.Get("content"); content.Exists() && content.IsArray() {
@@ -82,20 +84,16 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
switch contentType {
case "input_text":
case "input_text", "output_text":
text := contentItem.Get("text").String()
if messageContent != "" {
messageContent += "\n" + text
} else {
messageContent = text
}
case "output_text":
text := contentItem.Get("text").String()
if messageContent != "" {
messageContent += "\n" + text
} else {
messageContent = text
}
contentPart := `{"type":"text","text":""}`
contentPart, _ = sjson.Set(contentPart, "text", text)
message, _ = sjson.SetRaw(message, "content.-1", contentPart)
case "input_image":
imageURL := contentItem.Get("image_url").String()
contentPart := `{"type":"image_url","image_url":{"url":""}}`
contentPart, _ = sjson.Set(contentPart, "image_url.url", imageURL)
message, _ = sjson.SetRaw(message, "content.-1", contentPart)
}
return true
})
@@ -167,7 +165,8 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
// Only function tools need structural conversion because Chat Completions nests details under "function".
toolType := tool.Get("type").String()
if toolType != "" && toolType != "function" && tool.IsObject() {
chatCompletionsTools = append(chatCompletionsTools, tool.Value())
// Almost all providers lack built-in tools, so we just ignore them.
// chatCompletionsTools = append(chatCompletionsTools, tool.Value())
return true
}

View File

@@ -11,6 +11,7 @@ func TestIsClaudeThinkingModel(t *testing.T) {
// Claude thinking models - should return true
{"claude-sonnet-4-5-thinking", "claude-sonnet-4-5-thinking", true},
{"claude-opus-4-5-thinking", "claude-opus-4-5-thinking", true},
{"claude-opus-4-6-thinking", "claude-opus-4-6-thinking", true},
{"Claude-Sonnet-Thinking uppercase", "Claude-Sonnet-4-5-Thinking", true},
{"claude thinking mixed case", "Claude-THINKING-Model", true},

View File

@@ -61,14 +61,20 @@ func cleanJSONSchema(jsonStr string, addPlaceholder bool) string {
// removeKeywords removes all occurrences of specified keywords from the JSON schema.
func removeKeywords(jsonStr string, keywords []string) string {
deletePaths := make([]string, 0)
pathsByField := findPathsByFields(jsonStr, keywords)
for _, key := range keywords {
for _, p := range findPaths(jsonStr, key) {
for _, p := range pathsByField[key] {
if isPropertyDefinition(trimSuffix(p, "."+key)) {
continue
}
jsonStr, _ = sjson.Delete(jsonStr, p)
deletePaths = append(deletePaths, p)
}
}
sortByDepth(deletePaths)
for _, p := range deletePaths {
jsonStr, _ = sjson.Delete(jsonStr, p)
}
return jsonStr
}
@@ -235,8 +241,9 @@ var unsupportedConstraints = []string{
}
func moveConstraintsToDescription(jsonStr string) string {
pathsByField := findPathsByFields(jsonStr, unsupportedConstraints)
for _, key := range unsupportedConstraints {
for _, p := range findPaths(jsonStr, key) {
for _, p := range pathsByField[key] {
val := gjson.Get(jsonStr, p)
if !val.Exists() || val.IsObject() || val.IsArray() {
continue
@@ -424,14 +431,21 @@ func removeUnsupportedKeywords(jsonStr string) string {
"$schema", "$defs", "definitions", "const", "$ref", "additionalProperties",
"propertyNames", // Gemini doesn't support property name validation
)
deletePaths := make([]string, 0)
pathsByField := findPathsByFields(jsonStr, keywords)
for _, key := range keywords {
for _, p := range findPaths(jsonStr, key) {
for _, p := range pathsByField[key] {
if isPropertyDefinition(trimSuffix(p, "."+key)) {
continue
}
jsonStr, _ = sjson.Delete(jsonStr, p)
deletePaths = append(deletePaths, p)
}
}
sortByDepth(deletePaths)
for _, p := range deletePaths {
jsonStr, _ = sjson.Delete(jsonStr, p)
}
// Remove x-* extension fields (e.g., x-google-enum-descriptions) that are not supported by Gemini API
jsonStr = removeExtensionFields(jsonStr)
return jsonStr
@@ -581,6 +595,42 @@ func findPaths(jsonStr, field string) []string {
return paths
}
func findPathsByFields(jsonStr string, fields []string) map[string][]string {
set := make(map[string]struct{}, len(fields))
for _, field := range fields {
set[field] = struct{}{}
}
paths := make(map[string][]string, len(set))
walkForFields(gjson.Parse(jsonStr), "", set, paths)
return paths
}
func walkForFields(value gjson.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 {
keyStr := key.String()
safeKey := escapeGJSONPathKey(keyStr)
var childPath string
if path == "" {
childPath = safeKey
} else {
childPath = path + "." + safeKey
}
if _, ok := fields[keyStr]; ok {
paths[keyStr] = append(paths[keyStr], childPath)
}
walkForFields(val, childPath, fields, paths)
return true
})
case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
// Terminal types - no further traversal needed
}
}
func sortByDepth(paths []string) {
sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
}
@@ -667,6 +717,9 @@ func orDefault(val, def string) string {
}
func escapeGJSONPathKey(key string) string {
if strings.IndexAny(key, ".*?") == -1 {
return key
}
return gjsonPathKeyReplacer.Replace(key)
}

View File

@@ -6,7 +6,6 @@ package util
import (
"bytes"
"fmt"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -33,15 +32,15 @@ func Walk(value gjson.Result, path, field string, paths *[]string) {
// . -> \.
// * -> \*
// ? -> \?
var keyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
safeKey := keyReplacer.Replace(key.String())
keyStr := key.String()
safeKey := escapeGJSONPathKey(keyStr)
if path == "" {
childPath = safeKey
} else {
childPath = path + "." + safeKey
}
if key.String() == field {
if keyStr == field {
*paths = append(*paths, childPath)
}
Walk(val, childPath, field, paths)
@@ -87,15 +86,6 @@ func RenameKey(jsonStr, oldKeyPath, newKeyPath string) (string, error) {
return finalJson, nil
}
func DeleteKey(jsonStr, keyName string) string {
paths := make([]string, 0)
Walk(gjson.Parse(jsonStr), "", keyName, &paths)
for _, p := range paths {
jsonStr, _ = sjson.Delete(jsonStr, p)
}
return jsonStr
}
// FixJSON converts non-standard JSON that uses single quotes for strings into
// RFC 8259-compliant JSON by converting those single-quoted strings to
// double-quoted strings with proper escaping.

View File

@@ -6,6 +6,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
@@ -15,6 +16,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
@@ -72,6 +74,7 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
w.clientsMutex.Lock()
w.lastAuthHashes = make(map[string]string)
w.lastAuthContents = make(map[string]*coreauth.Auth)
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
log.Errorf("failed to resolve auth directory for hash cache: %v", errResolveAuthDir)
} else if resolvedAuthDir != "" {
@@ -84,6 +87,11 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
sum := sha256.Sum256(data)
normalizedPath := w.normalizeAuthPath(path)
w.lastAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])
// Parse and cache auth content for future diff comparisons
var auth coreauth.Auth
if errParse := json.Unmarshal(data, &auth); errParse == nil {
w.lastAuthContents[normalizedPath] = &auth
}
}
}
return nil
@@ -127,6 +135,13 @@ func (w *Watcher) addOrUpdateClient(path string) {
curHash := hex.EncodeToString(sum[:])
normalized := w.normalizeAuthPath(path)
// Parse new auth content for diff comparison
var newAuth coreauth.Auth
if errParse := json.Unmarshal(data, &newAuth); errParse != nil {
log.Errorf("failed to parse auth file %s: %v", filepath.Base(path), errParse)
return
}
w.clientsMutex.Lock()
cfg := w.config
@@ -141,7 +156,26 @@ func (w *Watcher) addOrUpdateClient(path string) {
return
}
// Get old auth for diff comparison
var oldAuth *coreauth.Auth
if w.lastAuthContents != nil {
oldAuth = w.lastAuthContents[normalized]
}
// Compute and log field changes
if changes := diff.BuildAuthChangeDetails(oldAuth, &newAuth); len(changes) > 0 {
log.Debugf("auth field changes for %s:", filepath.Base(path))
for _, c := range changes {
log.Debugf(" %s", c)
}
}
// Update caches
w.lastAuthHashes[normalized] = curHash
if w.lastAuthContents == nil {
w.lastAuthContents = make(map[string]*coreauth.Auth)
}
w.lastAuthContents[normalized] = &newAuth
w.clientsMutex.Unlock() // Unlock before the callback
@@ -160,6 +194,7 @@ func (w *Watcher) removeClient(path string) {
cfg := w.config
delete(w.lastAuthHashes, normalized)
delete(w.lastAuthContents, normalized)
w.clientsMutex.Unlock() // Release the lock before the callback

View File

@@ -0,0 +1,44 @@
// auth_diff.go computes human-readable diffs for auth file field changes.
package diff
import (
"fmt"
"strings"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
// BuildAuthChangeDetails computes a redacted, human-readable list of auth field changes.
// Only prefix, proxy_url, and disabled fields are tracked; sensitive data is never printed.
func BuildAuthChangeDetails(oldAuth, newAuth *coreauth.Auth) []string {
changes := make([]string, 0, 3)
// Handle nil cases by using empty Auth as default
if oldAuth == nil {
oldAuth = &coreauth.Auth{}
}
if newAuth == nil {
return changes
}
// Compare prefix
oldPrefix := strings.TrimSpace(oldAuth.Prefix)
newPrefix := strings.TrimSpace(newAuth.Prefix)
if oldPrefix != newPrefix {
changes = append(changes, fmt.Sprintf("prefix: %s -> %s", oldPrefix, newPrefix))
}
// Compare proxy_url (redacted)
oldProxy := strings.TrimSpace(oldAuth.ProxyURL)
newProxy := strings.TrimSpace(newAuth.ProxyURL)
if oldProxy != newProxy {
changes = append(changes, fmt.Sprintf("proxy_url: %s -> %s", formatProxyURL(oldProxy), formatProxyURL(newProxy)))
}
// Compare disabled
if oldAuth.Disabled != newAuth.Disabled {
changes = append(changes, fmt.Sprintf("disabled: %t -> %t", oldAuth.Disabled, newAuth.Disabled))
}
return changes
}

View File

@@ -27,6 +27,12 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.Debug != newCfg.Debug {
changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug))
}
if oldCfg.Pprof.Enable != newCfg.Pprof.Enable {
changes = append(changes, fmt.Sprintf("pprof.enable: %t -> %t", oldCfg.Pprof.Enable, newCfg.Pprof.Enable))
}
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.LoggingToFile != newCfg.LoggingToFile {
changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile))
}

View File

@@ -38,6 +38,7 @@ type Watcher struct {
reloadCallback func(*config.Config)
watcher *fsnotify.Watcher
lastAuthHashes map[string]string
lastAuthContents map[string]*coreauth.Auth
lastRemoveTimes map[string]time.Time
lastConfigHash string
authQueue chan<- AuthUpdate

View File

@@ -155,20 +155,6 @@ func requestExecutionMetadata(ctx context.Context) map[string]any {
return map[string]any{idempotencyKeyMetadataKey: key}
}
func mergeMetadata(base, overlay map[string]any) map[string]any {
if len(base) == 0 && len(overlay) == 0 {
return nil
}
out := make(map[string]any, len(base)+len(overlay))
for k, v := range base {
out[k] = v
}
for k, v := range overlay {
out[k] = v
}
return out
}
// BaseAPIHandler contains the handlers for API endpoints.
// It holds a pool of clients to interact with the backend service and manages
// load balancing, client selection, and configuration.
@@ -392,14 +378,18 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
}
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
payload := rawJSON
if len(payload) == 0 {
payload = nil
}
req := coreexecutor.Request{
Model: normalizedModel,
Payload: cloneBytes(rawJSON),
Payload: payload,
}
opts := coreexecutor.Options{
Stream: false,
Alt: alt,
OriginalRequest: cloneBytes(rawJSON),
OriginalRequest: rawJSON,
SourceFormat: sdktranslator.FromString(handlerType),
}
opts.Metadata = reqMeta
@@ -419,7 +409,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
}
return nil, &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon}
}
return cloneBytes(resp.Payload), nil
return resp.Payload, nil
}
// ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager.
@@ -431,14 +421,18 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
}
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
payload := rawJSON
if len(payload) == 0 {
payload = nil
}
req := coreexecutor.Request{
Model: normalizedModel,
Payload: cloneBytes(rawJSON),
Payload: payload,
}
opts := coreexecutor.Options{
Stream: false,
Alt: alt,
OriginalRequest: cloneBytes(rawJSON),
OriginalRequest: rawJSON,
SourceFormat: sdktranslator.FromString(handlerType),
}
opts.Metadata = reqMeta
@@ -458,7 +452,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
}
return nil, &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon}
}
return cloneBytes(resp.Payload), nil
return resp.Payload, nil
}
// ExecuteStreamWithAuthManager executes a streaming request via the core auth manager.
@@ -473,14 +467,18 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
}
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
payload := rawJSON
if len(payload) == 0 {
payload = nil
}
req := coreexecutor.Request{
Model: normalizedModel,
Payload: cloneBytes(rawJSON),
Payload: payload,
}
opts := coreexecutor.Options{
Stream: true,
Alt: alt,
OriginalRequest: cloneBytes(rawJSON),
OriginalRequest: rawJSON,
SourceFormat: sdktranslator.FromString(handlerType),
}
opts.Metadata = reqMeta
@@ -669,17 +667,6 @@ func cloneBytes(src []byte) []byte {
return dst
}
func cloneMetadata(src map[string]any) map[string]any {
if len(src) == 0 {
return nil
}
dst := make(map[string]any, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
status := http.StatusInternalServerError
@@ -710,7 +697,7 @@ func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.Erro
var previous []byte
if existing, exists := c.Get("API_RESPONSE"); exists {
if existingBytes, ok := existing.([]byte); ok && len(existingBytes) > 0 {
previous = bytes.Clone(existingBytes)
previous = existingBytes
}
}
appendAPIResponse(c, body)

View File

@@ -18,6 +18,7 @@ type ManagementTokenRequester interface {
RequestCodexToken(*gin.Context)
RequestAntigravityToken(*gin.Context)
RequestQwenToken(*gin.Context)
RequestKimiToken(*gin.Context)
RequestIFlowToken(*gin.Context)
RequestIFlowCookieToken(*gin.Context)
GetAuthStatus(c *gin.Context)
@@ -55,6 +56,10 @@ func (m *managementTokenRequester) RequestQwenToken(c *gin.Context) {
m.handler.RequestQwenToken(c)
}
func (m *managementTokenRequester) RequestKimiToken(c *gin.Context) {
m.handler.RequestKimiToken(c)
}
func (m *managementTokenRequester) RequestIFlowToken(c *gin.Context) {
m.handler.RequestIFlowToken(c)
}

123
sdk/auth/kimi.go Normal file
View File

@@ -0,0 +1,123 @@
package auth
import (
"context"
"fmt"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
// kimiRefreshLead is the duration before token expiry when refresh should occur.
var kimiRefreshLead = 5 * time.Minute
// KimiAuthenticator implements the OAuth device flow login for Kimi (Moonshot AI).
type KimiAuthenticator struct{}
// NewKimiAuthenticator constructs a new Kimi authenticator.
func NewKimiAuthenticator() Authenticator {
return &KimiAuthenticator{}
}
// Provider returns the provider key for kimi.
func (KimiAuthenticator) Provider() string {
return "kimi"
}
// RefreshLead returns the duration before token expiry when refresh should occur.
// Kimi tokens expire and need to be refreshed before expiry.
func (KimiAuthenticator) RefreshLead() *time.Duration {
return &kimiRefreshLead
}
// Login initiates the Kimi device flow authentication.
func (a KimiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
if cfg == nil {
return nil, fmt.Errorf("cliproxy auth: configuration is required")
}
if opts == nil {
opts = &LoginOptions{}
}
authSvc := kimi.NewKimiAuth(cfg)
// Start the device flow
fmt.Println("Starting Kimi authentication...")
deviceCode, err := authSvc.StartDeviceFlow(ctx)
if err != nil {
return nil, fmt.Errorf("kimi: failed to start device flow: %w", err)
}
// Display the verification URL
verificationURL := deviceCode.VerificationURIComplete
if verificationURL == "" {
verificationURL = deviceCode.VerificationURI
}
fmt.Printf("\nTo authenticate, please visit:\n%s\n\n", verificationURL)
if deviceCode.UserCode != "" {
fmt.Printf("User code: %s\n\n", deviceCode.UserCode)
}
// Try to open the browser automatically
if !opts.NoBrowser {
if browser.IsAvailable() {
if errOpen := browser.OpenURL(verificationURL); errOpen != nil {
log.Warnf("Failed to open browser automatically: %v", errOpen)
} else {
fmt.Println("Browser opened automatically.")
}
}
}
fmt.Println("Waiting for authorization...")
if deviceCode.ExpiresIn > 0 {
fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn)
}
// Wait for user authorization
authBundle, err := authSvc.WaitForAuthorization(ctx, deviceCode)
if err != nil {
return nil, fmt.Errorf("kimi: %w", err)
}
// Create the token storage
tokenStorage := authSvc.CreateTokenStorage(authBundle)
// Build metadata with token information
metadata := map[string]any{
"type": "kimi",
"access_token": authBundle.TokenData.AccessToken,
"refresh_token": authBundle.TokenData.RefreshToken,
"token_type": authBundle.TokenData.TokenType,
"scope": authBundle.TokenData.Scope,
"timestamp": time.Now().UnixMilli(),
}
if authBundle.TokenData.ExpiresAt > 0 {
exp := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
metadata["expired"] = exp
}
if strings.TrimSpace(authBundle.DeviceID) != "" {
metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID)
}
// Generate a unique filename
fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli())
fmt.Println("\nKimi authentication successful!")
return &coreauth.Auth{
ID: fileName,
Provider: a.Provider(),
FileName: fileName,
Label: "Kimi User",
Storage: tokenStorage,
Metadata: metadata,
}, nil
}

View File

@@ -70,14 +70,25 @@ func (a *KiroAuthenticator) createAuthRecord(tokenData *kiroauth.KiroTokenData,
}
// Determine label and identifier based on auth method
// Generate sequence number for uniqueness
seq := time.Now().UnixNano() % 100000
var label, idPart string
if tokenData.AuthMethod == "idc" {
label = "kiro-idc"
// For IDC auth, always use clientID as identifier
if tokenData.ClientID != "" {
idPart = kiroauth.SanitizeEmailForFilename(tokenData.ClientID)
// Priority: email > startUrl identifier > sequence only
// Email is unique, so no sequence needed when email is available
if tokenData.Email != "" {
idPart = kiroauth.SanitizeEmailForFilename(tokenData.Email)
} else if tokenData.StartURL != "" {
identifier := kiroauth.ExtractIDCIdentifier(tokenData.StartURL)
if identifier != "" {
idPart = fmt.Sprintf("%s-%05d", identifier, seq)
} else {
idPart = fmt.Sprintf("%05d", seq)
}
} else {
idPart = fmt.Sprintf("%d", time.Now().UnixNano()%100000)
idPart = fmt.Sprintf("%05d", seq)
}
} else {
label = fmt.Sprintf("kiro-%s", source)
@@ -126,14 +137,14 @@ func (a *KiroAuthenticator) createAuthRecord(tokenData *kiroauth.KiroTokenData,
}
record := &coreauth.Auth{
ID: fileName,
Provider: "kiro",
FileName: fileName,
Label: label,
Status: coreauth.StatusActive,
CreatedAt: now,
UpdatedAt: now,
Metadata: metadata,
ID: fileName,
Provider: "kiro",
FileName: fileName,
Label: label,
Status: coreauth.StatusActive,
CreatedAt: now,
UpdatedAt: now,
Metadata: metadata,
Attributes: attributes,
// NextRefreshAfter: 20 minutes before expiry
NextRefreshAfter: expiresAt.Add(-20 * time.Minute),

View File

@@ -14,6 +14,7 @@ func init() {
registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
registerRefreshLead("kimi", func() Authenticator { return NewKimiAuthenticator() })
registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })
registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
}

View File

@@ -607,6 +607,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
result.RetryAfter = ra
}
m.MarkResult(execCtx, result)
if isRequestInvalidError(errExec) {
return cliproxyexecutor.Response{}, errExec
}
lastErr = errExec
continue
}
@@ -660,6 +663,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
result.RetryAfter = ra
}
m.MarkResult(execCtx, result)
if isRequestInvalidError(errExec) {
return cliproxyexecutor.Response{}, errExec
}
lastErr = errExec
continue
}
@@ -711,6 +717,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr}
result.RetryAfter = retryAfterFromError(errStream)
m.MarkResult(execCtx, result)
if isRequestInvalidError(errStream) {
return nil, errStream
}
lastErr = errStream
continue
}
@@ -1110,6 +1119,9 @@ func (m *Manager) shouldRetryAfterError(err error, attempt int, providers []stri
if status := statusCodeFromError(err); status == http.StatusOK {
return 0, false
}
if isRequestInvalidError(err) {
return 0, false
}
wait, found := m.closestCooldownWait(providers, model, attempt)
if !found || wait > maxWait {
return 0, false
@@ -1299,7 +1311,7 @@ func updateAggregatedAvailability(auth *Auth, now time.Time) {
stateUnavailable = true
} else if state.Unavailable {
if state.NextRetryAfter.IsZero() {
stateUnavailable = true
stateUnavailable = false
} else if state.NextRetryAfter.After(now) {
stateUnavailable = true
if earliestRetry.IsZero() || state.NextRetryAfter.Before(earliestRetry) {
@@ -1430,6 +1442,21 @@ func statusCodeFromResult(err *Error) int {
return err.StatusCode()
}
// isRequestInvalidError returns true if the error represents a client request
// error that should not be retried. Specifically, it checks for 400 Bad Request
// with "invalid_request_error" in the message, indicating the request itself is
// malformed and switching to a different auth will not help.
func isRequestInvalidError(err error) bool {
if err == nil {
return false
}
status := statusCodeFromError(err)
if status != http.StatusBadRequest {
return false
}
return strings.Contains(err.Error(), "invalid_request_error")
}
func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Duration, now time.Time) {
if auth == nil {
return

View File

@@ -0,0 +1,61 @@
package auth
import (
"testing"
"time"
)
func TestUpdateAggregatedAvailability_UnavailableWithoutNextRetryDoesNotBlockAuth(t *testing.T) {
t.Parallel()
now := time.Now()
model := "test-model"
auth := &Auth{
ID: "a",
ModelStates: map[string]*ModelState{
model: {
Status: StatusError,
Unavailable: true,
},
},
}
updateAggregatedAvailability(auth, now)
if auth.Unavailable {
t.Fatalf("auth.Unavailable = true, want false")
}
if !auth.NextRetryAfter.IsZero() {
t.Fatalf("auth.NextRetryAfter = %v, want zero", auth.NextRetryAfter)
}
}
func TestUpdateAggregatedAvailability_FutureNextRetryBlocksAuth(t *testing.T) {
t.Parallel()
now := time.Now()
model := "test-model"
next := now.Add(5 * time.Minute)
auth := &Auth{
ID: "a",
ModelStates: map[string]*ModelState{
model: {
Status: StatusError,
Unavailable: true,
NextRetryAfter: next,
},
},
}
updateAggregatedAvailability(auth, now)
if !auth.Unavailable {
t.Fatalf("auth.Unavailable = false, want true")
}
if auth.NextRetryAfter.IsZero() {
t.Fatalf("auth.NextRetryAfter = zero, want %v", next)
}
if auth.NextRetryAfter.Sub(next) > time.Second || next.Sub(auth.NextRetryAfter) > time.Second {
t.Fatalf("auth.NextRetryAfter = %v, want %v", auth.NextRetryAfter, next)
}
}

View File

@@ -221,7 +221,7 @@ func modelAliasChannel(auth *Auth) string {
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
// OAuth model alias (e.g., API key authentication).
//
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot.
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, kimi.
func OAuthModelAliasChannel(provider, authKind string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
authKind = strings.ToLower(strings.TrimSpace(authKind))
@@ -245,7 +245,7 @@ func OAuthModelAliasChannel(provider, authKind string) string {
return ""
}
return "codex"
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow", "kiro", "github-copilot":
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow", "kiro", "github-copilot", "kimi":
return provider
default:
return ""

View File

@@ -79,6 +79,15 @@ func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {
input: "gemini-2.5-pro(none)",
want: "gemini-2.5-pro-exp-03-25(none)",
},
{
name: "kimi suffix preserved",
aliases: map[string][]internalconfig.OAuthModelAlias{
"kimi": {{Name: "kimi-k2.5", Alias: "k2.5"}},
},
channel: "kimi",
input: "k2.5(high)",
want: "kimi-k2.5(high)",
},
{
name: "case insensitive alias lookup with suffix",
aliases: map[string][]internalconfig.OAuthModelAlias{
@@ -161,6 +170,8 @@ func createAuthForChannel(channel string) *Auth {
return &Auth{Provider: "qwen"}
case "iflow":
return &Auth{Provider: "iflow"}
case "kimi":
return &Auth{Provider: "kimi"}
case "kiro":
return &Auth{Provider: "kiro"}
default:
@@ -168,6 +179,14 @@ func createAuthForChannel(channel string) *Auth {
}
}
func TestOAuthModelAliasChannel_Kimi(t *testing.T) {
t.Parallel()
if got := OAuthModelAliasChannel("kimi", "oauth"); got != "kimi" {
t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "kimi")
}
}
func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {
t.Parallel()

View File

@@ -12,6 +12,7 @@ import (
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
@@ -19,6 +20,7 @@ import (
type RoundRobinSelector struct {
mu sync.Mutex
cursors map[string]int
maxKeys int
}
// FillFirstSelector selects the first available credential (deterministic ordering).
@@ -119,6 +121,19 @@ func authPriority(auth *Auth) int {
return parsed
}
func canonicalModelKey(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
parsed := thinking.ParseSuffix(model)
modelName := strings.TrimSpace(parsed.ModelName)
if modelName == "" {
return model
}
return modelName
}
func collectAvailableByPriority(auths []*Auth, model string, now time.Time) (available map[int][]*Auth, cooldownCount int, earliest time.Time) {
available = make(map[int][]*Auth)
for i := 0; i < len(auths); i++ {
@@ -185,11 +200,18 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o
if err != nil {
return nil, err
}
key := provider + ":" + model
key := provider + ":" + canonicalModelKey(model)
s.mu.Lock()
if s.cursors == nil {
s.cursors = make(map[string]int)
}
limit := s.maxKeys
if limit <= 0 {
limit = 4096
}
if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit {
s.cursors = make(map[string]int)
}
index := s.cursors[key]
if index >= 2_147_483_640 {
@@ -223,7 +245,14 @@ func isAuthBlockedForModel(auth *Auth, model string, now time.Time) (bool, block
}
if model != "" {
if len(auth.ModelStates) > 0 {
if state, ok := auth.ModelStates[model]; ok && state != nil {
state, ok := auth.ModelStates[model]
if (!ok || state == nil) && model != "" {
baseModel := canonicalModelKey(model)
if baseModel != "" && baseModel != model {
state, ok = auth.ModelStates[baseModel]
}
}
if ok && state != nil {
if state.Status == StatusDisabled {
return true, blockReasonDisabled, time.Time{}
}

View File

@@ -2,7 +2,9 @@ package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync"
"testing"
"time"
@@ -175,3 +177,228 @@ func TestRoundRobinSelectorPick_Concurrent(t *testing.T) {
default:
}
}
func TestSelectorPick_AllCooldownReturnsModelCooldownError(t *testing.T) {
t.Parallel()
model := "test-model"
now := time.Now()
next := now.Add(60 * time.Second)
auths := []*Auth{
{
ID: "a",
ModelStates: map[string]*ModelState{
model: {
Status: StatusActive,
Unavailable: true,
NextRetryAfter: next,
Quota: QuotaState{
Exceeded: true,
NextRecoverAt: next,
},
},
},
},
{
ID: "b",
ModelStates: map[string]*ModelState{
model: {
Status: StatusActive,
Unavailable: true,
NextRetryAfter: next,
Quota: QuotaState{
Exceeded: true,
NextRecoverAt: next,
},
},
},
},
}
t.Run("mixed provider redacts provider field", func(t *testing.T) {
t.Parallel()
selector := &FillFirstSelector{}
_, err := selector.Pick(context.Background(), "mixed", model, cliproxyexecutor.Options{}, auths)
if err == nil {
t.Fatalf("Pick() error = nil")
}
var mce *modelCooldownError
if !errors.As(err, &mce) {
t.Fatalf("Pick() error = %T, want *modelCooldownError", err)
}
if mce.StatusCode() != http.StatusTooManyRequests {
t.Fatalf("StatusCode() = %d, want %d", mce.StatusCode(), http.StatusTooManyRequests)
}
headers := mce.Headers()
if got := headers.Get("Retry-After"); got == "" {
t.Fatalf("Headers().Get(Retry-After) = empty")
}
var payload map[string]any
if err := json.Unmarshal([]byte(mce.Error()), &payload); err != nil {
t.Fatalf("json.Unmarshal(Error()) error = %v", err)
}
rawErr, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("Error() payload missing error object: %v", payload)
}
if got, _ := rawErr["code"].(string); got != "model_cooldown" {
t.Fatalf("Error().error.code = %q, want %q", got, "model_cooldown")
}
if _, ok := rawErr["provider"]; ok {
t.Fatalf("Error().error.provider exists for mixed provider: %v", rawErr["provider"])
}
})
t.Run("non-mixed provider includes provider field", func(t *testing.T) {
t.Parallel()
selector := &FillFirstSelector{}
_, err := selector.Pick(context.Background(), "gemini", model, cliproxyexecutor.Options{}, auths)
if err == nil {
t.Fatalf("Pick() error = nil")
}
var mce *modelCooldownError
if !errors.As(err, &mce) {
t.Fatalf("Pick() error = %T, want *modelCooldownError", err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(mce.Error()), &payload); err != nil {
t.Fatalf("json.Unmarshal(Error()) error = %v", err)
}
rawErr, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("Error() payload missing error object: %v", payload)
}
if got, _ := rawErr["provider"].(string); got != "gemini" {
t.Fatalf("Error().error.provider = %q, want %q", got, "gemini")
}
})
}
func TestIsAuthBlockedForModel_UnavailableWithoutNextRetryIsNotBlocked(t *testing.T) {
t.Parallel()
now := time.Now()
model := "test-model"
auth := &Auth{
ID: "a",
ModelStates: map[string]*ModelState{
model: {
Status: StatusActive,
Unavailable: true,
Quota: QuotaState{
Exceeded: true,
},
},
},
}
blocked, reason, next := isAuthBlockedForModel(auth, model, now)
if blocked {
t.Fatalf("blocked = true, want false")
}
if reason != blockReasonNone {
t.Fatalf("reason = %v, want %v", reason, blockReasonNone)
}
if !next.IsZero() {
t.Fatalf("next = %v, want zero", next)
}
}
func TestFillFirstSelectorPick_ThinkingSuffixFallsBackToBaseModelState(t *testing.T) {
t.Parallel()
selector := &FillFirstSelector{}
now := time.Now()
baseModel := "test-model"
requestedModel := "test-model(high)"
high := &Auth{
ID: "high",
Attributes: map[string]string{"priority": "10"},
ModelStates: map[string]*ModelState{
baseModel: {
Status: StatusActive,
Unavailable: true,
NextRetryAfter: now.Add(30 * time.Minute),
Quota: QuotaState{
Exceeded: true,
},
},
},
}
low := &Auth{
ID: "low",
Attributes: map[string]string{"priority": "0"},
}
got, err := selector.Pick(context.Background(), "mixed", requestedModel, cliproxyexecutor.Options{}, []*Auth{high, low})
if err != nil {
t.Fatalf("Pick() error = %v", err)
}
if got == nil {
t.Fatalf("Pick() auth = nil")
}
if got.ID != "low" {
t.Fatalf("Pick() auth.ID = %q, want %q", got.ID, "low")
}
}
func TestRoundRobinSelectorPick_ThinkingSuffixSharesCursor(t *testing.T) {
t.Parallel()
selector := &RoundRobinSelector{}
auths := []*Auth{
{ID: "b"},
{ID: "a"},
}
first, err := selector.Pick(context.Background(), "gemini", "test-model(high)", cliproxyexecutor.Options{}, auths)
if err != nil {
t.Fatalf("Pick() first error = %v", err)
}
second, err := selector.Pick(context.Background(), "gemini", "test-model(low)", cliproxyexecutor.Options{}, auths)
if err != nil {
t.Fatalf("Pick() second error = %v", err)
}
if first == nil || second == nil {
t.Fatalf("Pick() returned nil auth")
}
if first.ID != "a" {
t.Fatalf("Pick() first auth.ID = %q, want %q", first.ID, "a")
}
if second.ID != "b" {
t.Fatalf("Pick() second auth.ID = %q, want %q", second.ID, "b")
}
}
func TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) {
t.Parallel()
selector := &RoundRobinSelector{maxKeys: 2}
auths := []*Auth{{ID: "a"}}
_, _ = selector.Pick(context.Background(), "gemini", "m1", cliproxyexecutor.Options{}, auths)
_, _ = selector.Pick(context.Background(), "gemini", "m2", cliproxyexecutor.Options{}, auths)
_, _ = selector.Pick(context.Background(), "gemini", "m3", cliproxyexecutor.Options{}, auths)
selector.mu.Lock()
defer selector.mu.Unlock()
if selector.cursors == nil {
t.Fatalf("selector.cursors = nil")
}
if len(selector.cursors) != 1 {
t.Fatalf("len(selector.cursors) = %d, want %d", len(selector.cursors), 1)
}
if _, ok := selector.cursors["gemini:m3"]; !ok {
t.Fatalf("selector.cursors missing key %q", "gemini:m3")
}
}

Some files were not shown because too many files have changed in this diff Show More