Compare commits

...

52 Commits

Author SHA1 Message Date
Luis Pater
726f1a590c Merge branch 'router-for-me:main' into main 2026-02-12 22:43:44 +08:00
Luis Pater
575881cb59 feat(registry): add new model definition for MiniMax-M2.5 2026-02-12 22:43:01 +08:00
Luis Pater
d02df0141b Merge pull request #224 from Buywatermelon/fix/kiro-assistant-first-message
fix(kiro): prepend placeholder user message when conversation starts with assistant role
2026-02-12 15:11:10 +08:00
Luis Pater
e4bc9da913 Merge pull request #220 from jellyfish-p/main
fix(kiro): 修复之前提交的错误的application/cbor请求处理逻辑
2026-02-12 15:10:42 +08:00
Luis Pater
8c6be49625 Merge pull request #218 from ClubWeGo/fix/merge-assistant-tool-calls
fix: prevent merging assistant messages with tool_calls
2026-02-12 15:10:00 +08:00
Luis Pater
c727e4251f ci(github): trigger Docker image workflow on version tags matching v* 2026-02-12 15:09:16 +08:00
Luis Pater
99266be998 Merge pull request #216 from starsdream666/main
增加kiro新模型并根据其他提供商同模型配置Thinking
2026-02-12 15:08:37 +08:00
Luis Pater
d0f3fd96f8 Merge pull request #225 from router-for-me/main
v6.8.13
2026-02-12 15:06:32 +08:00
hkfires
f361b2716d feat(registry): add glm-5 model to iflow 2026-02-12 11:13:28 +08:00
y
086d8d0d0b fix(kiro): prepend placeholder user message when conversation starts with assistant role
Kiro/AmazonQ API requires the conversation history to start with a user message.
Some clients (e.g., OpenClaw) send conversations starting with an assistant message,
which is valid for the native Claude API but causes 'Improperly formed request' (400)
on the Kiro endpoint.

This fix detects when the first message has role=assistant and prepends a minimal
placeholder user message ('.') to satisfy the Kiro API's message ordering requirement.

Upstream error: {"message":"Improperly formed request.","reason":null}
Verified: original request returns 400, fixed request returns 200.
2026-02-12 11:09:47 +08:00
jellyfish-p
627dee1dac fix(kiro): 修复之前提交的错误的application/cbor请求处理逻辑 2026-02-12 09:57:34 +08:00
Darley
55c3197fb8 fix(kiro): merge adjacent assistant messages while preserving tool_calls 2026-02-12 07:30:36 +08:00
Darley
5a2cf0d53c fix: prevent merging assistant messages with tool_calls
Adjacent assistant messages where any message contains tool_calls
were being merged by MergeAdjacentMessages, causing tool_calls to
be silently dropped. This led to orphaned tool results that could
not match any toolUse in history, resulting in Kiro API returning
'Improperly formed request.'

Now assistant messages with tool_calls are kept separate during
merge, preserving the tool call chain integrity.
2026-02-12 01:53:40 +08:00
starsdream666
2573358173 根据其他提供商同模型配置Thinking 2026-02-12 00:41:13 +08:00
starsdream666
09cd3cff91 增加kiro新模型:deepseek-3.2,minimax-m2.1,qwen3-coder-next,gpt-4o,gpt-4,gpt-4-turbo,gpt-3.5-turbo 2026-02-12 00:35:24 +08:00
starsdream666
ab0bf1b517 Merge branch 'router-for-me:main' into main 2026-02-11 16:20:20 +00:00
Luis Pater
58e09f8e5f Merge pull request #1542 from APE-147/fix/gemini-antigravity-schema-sanitization
fix(schema): sanitize Gemini-incompatible tool metadata fields
2026-02-11 21:34:04 +08:00
Luis Pater
2334a2b174 Merge branch 'router-for-me:main' into main 2026-02-11 21:09:34 +08:00
Luis Pater
bc61bf36b2 Merge pull request #214 from anilcancakir/fix/github-copilot-model-alias-suffix
fix(auth): strip model suffix in GitHub Copilot executor before upstream call
2026-02-11 21:06:58 +08:00
Luis Pater
7726a44ca2 Merge pull request #212 from Skyuno/fix/orphaned-tool-results
fix(kiro): filter orphaned tool_results from compacted conversations
2026-02-11 21:06:20 +08:00
Luis Pater
dc55fb0ce3 Merge pull request #211 from Skyuno/fix/kiro-websearch
fix(kiro): fully implement Kiro web search tool via MCP integration
2026-02-11 21:05:21 +08:00
Luis Pater
a146c6c0aa Merge pull request #1523 from xxddff/feature/removeUserField
fix(codex): remove unsupported 'user' field from /v1/responses payload
2026-02-11 20:38:16 +08:00
Luis Pater
4c133d3ea9 test(sdk/watcher): add tests for excluded models merging and priority parsing logic
- Added unit tests for combining OAuth excluded models across global and attribute-specific scopes.
- Implemented priority attribute parsing with support for different formats and trimming.
2026-02-11 20:35:13 +08:00
starsdream666
544238772a Merge branch 'router-for-me:main' into main 2026-02-11 10:58:06 +00:00
RGBadmin
dc279de443 refactor: reduce code duplication in extractExcludedModelsFromMetadata 2026-02-11 15:57:16 +08:00
RGBadmin
bf1634bda0 refactor: simplify per-account excluded_models merge in routing 2026-02-11 15:57:15 +08:00
Nathan
166d2d24d9 fix(schema): remove Gemini-incompatible tool metadata fields
Sanitize tool schemas by stripping prefill, enumTitles, $id, and patternProperties to prevent Gemini INVALID_ARGUMENT 400 errors, and add unit and executor-level tests to lock in the behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 18:29:17 +11:00
RGBadmin
4cbcc835d1 feat: read per-account excluded_models at routing time 2026-02-11 15:21:19 +08:00
RGBadmin
b93026d83a feat: merge per-account excluded_models with global config 2026-02-11 15:21:15 +08:00
RGBadmin
5ed2133ff9 feat: add per-account excluded_models and priority parsing 2026-02-11 15:21:12 +08:00
Luis Pater
e9dd44e623 Merge pull request #209 from Buywatermelon/feature/default-kiro-aliases
feat(config): add default Kiro model aliases for standard Claude model names
2026-02-11 15:09:00 +08:00
Luis Pater
cc8c4ffb5f Merge branch 'router-for-me:main' into main 2026-02-11 15:07:06 +08:00
Luis Pater
1510bfcb6f fix(translator): improve content handling for system and user messages
- Added support for single and array-based `content` cases.
- Enhanced `system_instruction` structure population logic.
- Improved handling of user role assignment for string-based `content`.
2026-02-11 15:04:01 +08:00
Anilcan Cakir
bcd2208b51 fix(auth): strip model suffix in GitHub Copilot executor before upstream call
GitHub Copilot API rejects model names with suffixes (e.g. claude-opus-4.6(medium)).
The OAuthModelAlias resolution correctly maps aliases like 'opus(medium)' to
'claude-opus-4.6(medium)' preserving the suffix, but the executor must strip the
suffix before sending to the upstream API since Copilot only accepts bare model names.

Update normalizeModel in github_copilot_executor to strip suffixes using
thinking.ParseSuffix, matching the pattern used by other executors.

Also add test coverage for:
- OAuthModelAliasChannel github-copilot and kiro channel resolution
- Suffix preservation in alias resolution for github-copilot
- normalizeModel suffix stripping in github_copilot_executor
2026-02-10 23:34:19 +03:00
Skyuno
09b19f5c4e fix(kiro): filter orphaned tool_results from compacted conversations 2026-02-11 00:23:05 +08:00
Skyuno
7b01ca0e2e fix(kiro): implement web search MCP integration for streaming and non-streaming paths
Add complete web search functionality that routes pure web_search requests to the Kiro MCP endpoint instead of the normal GAR API.

Executor changes (kiro_executor.go):

- Add web_search detection in Execute() and ExecuteStream() entry points using HasWebSearchTool() to intercept pure web_search requests before normal processing

- Add 'kiro' format passthrough in buildKiroPayloadForFormat() for pre-built payloads used by callKiroRawAndBuffer()

- Implement handleWebSearchStream(): streaming search loop with MCP search -> InjectToolResultsClaude -> callKiroAndBuffer, supporting up to 5 search iterations with model-driven re-search

- Implement handleWebSearch(): non-streaming path that performs single MCP search, injects tool results, calls normal Execute path, and appends server_tool_use indicators to response

- Add helper methods: callKiroAndBuffer(), callKiroRawAndBuffer(), callKiroDirectStream(), sendFallbackText(), executeNonStreamFallback()

Web search core logic (kiro_websearch.go) [NEW]:

- Define MCP JSON-RPC 2.0 types (McpRequest, McpResponse, McpResult, McpContent, McpError)

- Define WebSearchResults/WebSearchResult structs for parsing MCP search results

- HasWebSearchTool(): detect pure web_search requests (single-tool array only)

- ContainsWebSearchTool(): detect web_search in mixed-tool arrays

- ExtractSearchQuery(): parse search query from Claude Code's tool_use message format

- CreateMcpRequest(): build MCP tools/call request with Kiro-compatible ID format

- InjectToolResultsClaude(): append assistant tool_use + user tool_result messages to Claude-format payload for GAR translation pipeline

- InjectToolResults(): modify Kiro-format payload directly with toolResults in currentMessage context

- InjectSearchIndicatorsInResponse(): prepend server_tool_use + web_search_tool_result content blocks to non-streaming response for Claude Code search count display

- ReplaceWebSearchToolDescription(): swap restrictive Kiro tool description with minimal re-search-friendly version

- StripWebSearchTool(): remove web_search from tools array

- FormatSearchContextPrompt() / FormatToolResultText(): format search results for injection

- SSE event generation: SseEvent type, GenerateWebSearchEvents() (11-event sequence), GenerateSearchIndicatorEvents() (server_tool_use + web_search_tool_result pairs)

- Stream analysis: AnalyzeBufferedStream() to detect stop_reason and web_search tool_use in buffered chunks, FilterChunksForClient() to strip tool_use blocks and adjust indices, AdjustSSEChunk() / AdjustStreamIndices() for content block index offset management

MCP API handler (kiro_websearch_handler.go) [NEW]:

- WebSearchHandler struct with MCP endpoint, HTTP client, auth token, fingerprint, and custom auth attributes

- FetchToolDescription(): sync.Once-guarded MCP tools/list call to cache web_search tool description

- GetWebSearchDescription(): thread-safe cached description retrieval

- CallMcpAPI(): MCP API caller with retry logic (exponential backoff, retryable on 502/503/504), AWS-aligned headers via setMcpHeaders()

- ParseSearchResults(): extract WebSearchResults from MCP JSON-RPC response

- setMcpHeaders(): set Content-Type, Kiro agent headers, dynamic fingerprint User-Agent, AWS SDK identifiers, Bearer auth, and custom auth attributes

Claude request translation (kiro_claude_request.go):

- Rename web_search -> remote_web_search in convertClaudeToolsToKiro() with dynamic description from GetWebSearchDescription() or hardcoded fallback

- Rename web_search -> remote_web_search in BuildAssistantMessageStruct() for tool_use content blocks

- Add remoteWebSearchDescription constant as fallback when MCP tools/list hasn't been fetched
2026-02-11 00:02:30 +08:00
starsdream666
9c65e17a21 Merge branch 'router-for-me:main' into main 2026-02-10 14:41:20 +00:00
Skyuno
fe6fc628ed Revert "fix: filter out web_search/websearch tools unsupported by Kiro API"
This reverts commit 5dc936a9a4.
2026-02-10 22:24:46 +08:00
Skyuno
8192eeabc8 Revert "feat: inject web_search alternative hint instead of silently filtering"
This reverts commit 3c7a5afdcc.
2026-02-10 22:24:46 +08:00
y
c3f1cdd7e5 feat(config): add default Kiro model aliases for standard Claude model names
Kiro models are exposed with kiro- prefix (e.g., kiro-claude-sonnet-4-5),
which prevents clients like Claude Code from using standard model names
(e.g., claude-sonnet-4-20250514).

This change injects default oauth-model-alias entries for the kiro channel
when no user-configured aliases exist, following the same pattern as the
existing Antigravity defaults. The aliases map standard Claude model names
(both with and without date suffixes) to their kiro-prefixed counterparts.

Default aliases added:
- claude-sonnet-4-5-20250929 / claude-sonnet-4-5 → kiro-claude-sonnet-4-5
- claude-sonnet-4-20250514 / claude-sonnet-4 → kiro-claude-sonnet-4
- claude-opus-4-6 → kiro-claude-opus-4-6
- claude-opus-4-5-20251101 / claude-opus-4-5 → kiro-claude-opus-4-5
- claude-haiku-4-5-20251001 / claude-haiku-4-5 → kiro-claude-haiku-4-5

All aliases use fork: true to preserve the original kiro-* names.
User-configured kiro aliases are respected and not overridden.

Closes router-for-me/CLIProxyAPIPlus#208
2026-02-10 19:01:07 +08:00
Chén Mù
c6bd91b86b Merge pull request #1519 from router-for-me/thinking
feat(translator): support Claude thinking type adaptive
2026-02-10 18:31:56 +08:00
hkfires
349ddcaa89 fix(registry): correct max completion tokens for opus 4.6 thinking 2026-02-10 18:05:40 +08:00
xxddff
bb9fe52f1e Update internal/translator/codex/openai/responses/codex_openai-responses_request_test.go
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-10 18:24:58 +09:00
xxddff
afe4c1bfb7 更新internal/translator/codex/openai/responses/codex_openai-responses_request.go
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-10 18:24:26 +09:00
xxddff
865af9f19e Implement test for user field deletion
Add test to verify deletion of user field in response
2026-02-10 17:38:49 +09:00
xxddff
2b97cb98b5 Delete 'user' field from raw JSON
Remove the 'user' field from the raw JSON as requested.
2026-02-10 17:35:54 +09:00
hkfires
938a799263 feat(translator): support Claude thinking type adaptive 2026-02-10 16:20:32 +08:00
starsdream666
15bc99f6ea Merge branch 'router-for-me:main' into main 2026-02-10 01:45:05 +00:00
starsdream666
3ec7991e5f Merge branch 'router-for-me:main' into main 2026-02-09 14:18:04 +00:00
starsdream666
40e85a6759 Merge branch 'router-for-me:main' into main 2026-02-07 16:37:51 +00:00
starsdream666
cc116ce67d Merge branch 'router-for-me:main' into main 2026-02-06 16:11:26 +00:00
starsdream666
40efc2ba43 修改工作流 2026-02-06 03:29:31 +00:00
33 changed files with 3353 additions and 77 deletions

View File

@@ -1,6 +1,7 @@
package management
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -189,9 +190,21 @@ func (h *Handler) APICall(c *gin.Context) {
reqHeaders[key] = strings.ReplaceAll(value, "$TOKEN$", token)
}
// When caller indicates CBOR in request headers, convert JSON string payload to CBOR bytes.
useCBORPayload := headerContainsValue(reqHeaders, "Content-Type", "application/cbor")
var requestBody io.Reader
if body.Data != "" {
requestBody = strings.NewReader(body.Data)
if useCBORPayload {
cborPayload, errEncode := encodeJSONStringToCBOR(body.Data)
if errEncode != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json data for cbor content-type"})
return
}
requestBody = bytes.NewReader(cborPayload)
} else {
requestBody = strings.NewReader(body.Data)
}
}
req, errNewRequest := http.NewRequestWithContext(c.Request.Context(), method, urlStr, requestBody)
@@ -234,10 +247,18 @@ func (h *Handler) APICall(c *gin.Context) {
return
}
// For CBOR upstream responses, decode into plain text or JSON string before returning.
responseBodyText := string(respBody)
if headerContainsValue(reqHeaders, "Accept", "application/cbor") || strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/cbor") {
if decodedBody, errDecode := decodeCBORBodyToTextOrJSON(respBody); errDecode == nil {
responseBodyText = decodedBody
}
}
response := apiCallResponse{
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: string(respBody),
Body: responseBodyText,
}
// If this is a GitHub Copilot token endpoint response, try to enrich with quota information
@@ -747,6 +768,83 @@ func buildProxyTransport(proxyStr string) *http.Transport {
return nil
}
// headerContainsValue checks whether a header map contains a target value (case-insensitive key and value).
func headerContainsValue(headers map[string]string, targetKey, targetValue string) bool {
if len(headers) == 0 {
return false
}
for key, value := range headers {
if !strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(targetKey)) {
continue
}
if strings.Contains(strings.ToLower(value), strings.ToLower(strings.TrimSpace(targetValue))) {
return true
}
}
return false
}
// encodeJSONStringToCBOR converts a JSON string payload into CBOR bytes.
func encodeJSONStringToCBOR(jsonString string) ([]byte, error) {
var payload any
if errUnmarshal := json.Unmarshal([]byte(jsonString), &payload); errUnmarshal != nil {
return nil, errUnmarshal
}
return cbor.Marshal(payload)
}
// decodeCBORBodyToTextOrJSON decodes CBOR bytes to plain text (for string payloads) or JSON string.
func decodeCBORBodyToTextOrJSON(raw []byte) (string, error) {
if len(raw) == 0 {
return "", nil
}
var payload any
if errUnmarshal := cbor.Unmarshal(raw, &payload); errUnmarshal != nil {
return "", errUnmarshal
}
jsonCompatible := cborValueToJSONCompatible(payload)
switch typed := jsonCompatible.(type) {
case string:
return typed, nil
case []byte:
return string(typed), nil
default:
jsonBytes, errMarshal := json.Marshal(jsonCompatible)
if errMarshal != nil {
return "", errMarshal
}
return string(jsonBytes), nil
}
}
// cborValueToJSONCompatible recursively converts CBOR-decoded values into JSON-marshalable values.
func cborValueToJSONCompatible(value any) any {
switch typed := value.(type) {
case map[any]any:
out := make(map[string]any, len(typed))
for key, item := range typed {
out[fmt.Sprint(key)] = cborValueToJSONCompatible(item)
}
return out
case map[string]any:
out := make(map[string]any, len(typed))
for key, item := range typed {
out[key] = cborValueToJSONCompatible(item)
}
return out
case []any:
out := make([]any, len(typed))
for i, item := range typed {
out[i] = cborValueToJSONCompatible(item)
}
return out
default:
return typed
}
}
// QuotaDetail represents quota information for a specific resource type
type QuotaDetail struct {
Entitlement float64 `json:"entitlement"`

View File

@@ -736,8 +736,32 @@ func payloadRawString(value any) ([]byte, bool) {
// SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
// allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
// It also injects default aliases for channels that have built-in defaults (e.g., kiro)
// when no user-configured aliases exist for those channels.
func (cfg *Config) SanitizeOAuthModelAlias() {
if cfg == nil || len(cfg.OAuthModelAlias) == 0 {
if cfg == nil {
return
}
// Inject default Kiro aliases if no user-configured kiro aliases exist
if cfg.OAuthModelAlias == nil {
cfg.OAuthModelAlias = make(map[string][]OAuthModelAlias)
}
if _, hasKiro := cfg.OAuthModelAlias["kiro"]; !hasKiro {
// Check case-insensitive too
found := false
for k := range cfg.OAuthModelAlias {
if strings.EqualFold(strings.TrimSpace(k), "kiro") {
found = true
break
}
}
if !found {
cfg.OAuthModelAlias["kiro"] = defaultKiroAliases()
}
}
if len(cfg.OAuthModelAlias) == 0 {
return
}
out := make(map[string][]OAuthModelAlias, len(cfg.OAuthModelAlias))

View File

@@ -20,6 +20,28 @@ var antigravityModelConversionTable = map[string]string{
"gemini-claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
}
// defaultKiroAliases returns the default oauth-model-alias configuration
// for the kiro channel. Maps kiro-prefixed model names to standard Claude model
// names so that clients like Claude Code can use standard names directly.
func defaultKiroAliases() []OAuthModelAlias {
return []OAuthModelAlias{
// Sonnet 4.5
{Name: "kiro-claude-sonnet-4-5", Alias: "claude-sonnet-4-5-20250929", Fork: true},
{Name: "kiro-claude-sonnet-4-5", Alias: "claude-sonnet-4-5", Fork: true},
// Sonnet 4
{Name: "kiro-claude-sonnet-4", Alias: "claude-sonnet-4-20250514", Fork: true},
{Name: "kiro-claude-sonnet-4", Alias: "claude-sonnet-4", Fork: true},
// Opus 4.6
{Name: "kiro-claude-opus-4-6", Alias: "claude-opus-4-6", Fork: true},
// Opus 4.5
{Name: "kiro-claude-opus-4-5", Alias: "claude-opus-4-5-20251101", Fork: true},
{Name: "kiro-claude-opus-4-5", Alias: "claude-opus-4-5", Fork: true},
// Haiku 4.5
{Name: "kiro-claude-haiku-4-5", Alias: "claude-haiku-4-5-20251001", Fork: true},
{Name: "kiro-claude-haiku-4-5", Alias: "claude-haiku-4-5", Fork: true},
}
}
// defaultAntigravityAliases returns the default oauth-model-alias configuration
// for the antigravity channel when neither field exists.
func defaultAntigravityAliases() []OAuthModelAlias {

View File

@@ -54,3 +54,88 @@ func TestSanitizeOAuthModelAlias_AllowsMultipleAliasesForSameName(t *testing.T)
}
}
}
func TestSanitizeOAuthModelAlias_InjectsDefaultKiroAliases(t *testing.T) {
// When no kiro aliases are configured, defaults should be injected
cfg := &Config{
OAuthModelAlias: map[string][]OAuthModelAlias{
"codex": {
{Name: "gpt-5", Alias: "g5"},
},
},
}
cfg.SanitizeOAuthModelAlias()
kiroAliases := cfg.OAuthModelAlias["kiro"]
if len(kiroAliases) == 0 {
t.Fatal("expected default kiro aliases to be injected")
}
// Check that standard Claude model names are present
aliasSet := make(map[string]bool)
for _, a := range kiroAliases {
aliasSet[a.Alias] = true
}
expectedAliases := []string{
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-5",
"claude-sonnet-4-20250514",
"claude-sonnet-4",
"claude-opus-4-6",
"claude-opus-4-5-20251101",
"claude-opus-4-5",
"claude-haiku-4-5-20251001",
"claude-haiku-4-5",
}
for _, expected := range expectedAliases {
if !aliasSet[expected] {
t.Fatalf("expected default kiro alias %q to be present", expected)
}
}
// All should have fork=true
for _, a := range kiroAliases {
if !a.Fork {
t.Fatalf("expected all default kiro aliases to have fork=true, got fork=false for %q", a.Alias)
}
}
// Codex aliases should still be preserved
if len(cfg.OAuthModelAlias["codex"]) != 1 {
t.Fatal("expected codex aliases to be preserved")
}
}
func TestSanitizeOAuthModelAlias_DoesNotOverrideUserKiroAliases(t *testing.T) {
// When user has configured kiro aliases, defaults should NOT be injected
cfg := &Config{
OAuthModelAlias: map[string][]OAuthModelAlias{
"kiro": {
{Name: "kiro-claude-sonnet-4", Alias: "my-custom-sonnet", Fork: true},
},
},
}
cfg.SanitizeOAuthModelAlias()
kiroAliases := cfg.OAuthModelAlias["kiro"]
if len(kiroAliases) != 1 {
t.Fatalf("expected 1 user-configured kiro alias, got %d", len(kiroAliases))
}
if kiroAliases[0].Alias != "my-custom-sonnet" {
t.Fatalf("expected user alias to be preserved, got %q", kiroAliases[0].Alias)
}
}
func TestSanitizeOAuthModelAlias_InjectsDefaultKiroWhenEmpty(t *testing.T) {
// When OAuthModelAlias is nil, kiro defaults should still be injected
cfg := &Config{}
cfg.SanitizeOAuthModelAlias()
kiroAliases := cfg.OAuthModelAlias["kiro"]
if len(kiroAliases) == 0 {
t.Fatal("expected default kiro aliases to be injected when OAuthModelAlias is nil")
}
}

View File

@@ -448,6 +448,87 @@ func GetKiroModels() []*ModelInfo {
MaxCompletionTokens: 64000,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
// --- 第三方模型 (通过 Kiro 接入) ---
{
ID: "kiro-deepseek-3-2",
Object: "model",
Created: 1732752000,
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro DeepSeek 3.2",
Description: "DeepSeek 3.2 via Kiro",
ContextLength: 128000,
MaxCompletionTokens: 32768,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
{
ID: "kiro-minimax-m2-1",
Object: "model",
Created: 1732752000,
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro MiniMax M2.1",
Description: "MiniMax M2.1 via Kiro",
ContextLength: 200000,
MaxCompletionTokens: 64000,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
{
ID: "kiro-qwen3-coder-next",
Object: "model",
Created: 1732752000,
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro Qwen3 Coder Next",
Description: "Qwen3 Coder Next via Kiro",
ContextLength: 128000,
MaxCompletionTokens: 32768,
Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
},
{
ID: "kiro-gpt-4o",
Object: "model",
Created: 1732752000,
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro GPT-4o",
Description: "OpenAI GPT-4o via Kiro",
ContextLength: 128000,
MaxCompletionTokens: 16384,
},
{
ID: "kiro-gpt-4",
Object: "model",
Created: 1732752000,
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro GPT-4",
Description: "OpenAI GPT-4 via Kiro",
ContextLength: 128000,
MaxCompletionTokens: 8192,
},
{
ID: "kiro-gpt-4-turbo",
Object: "model",
Created: 1732752000,
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro GPT-4 Turbo",
Description: "OpenAI GPT-4 Turbo via Kiro",
ContextLength: 128000,
MaxCompletionTokens: 16384,
},
{
ID: "kiro-gpt-3-5-turbo",
Object: "model",
Created: 1732752000,
OwnedBy: "aws",
Type: "kiro",
DisplayName: "Kiro GPT-3.5 Turbo",
Description: "OpenAI GPT-3.5 Turbo via Kiro",
ContextLength: 16384,
MaxCompletionTokens: 4096,
},
// --- Agentic Variants (Optimized for coding agents with chunked writes) ---
{
ID: "kiro-claude-opus-4-6-agentic",

View File

@@ -814,6 +814,7 @@ func GetIFlowModels() []*ModelInfo {
{ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905", Created: 1757030400},
{ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model", Created: 1759190400, Thinking: iFlowThinkingSupport},
{ID: "glm-4.7", DisplayName: "GLM-4.7", Description: "Zhipu GLM 4.7 general model", Created: 1766448000, Thinking: iFlowThinkingSupport},
{ID: "glm-5", DisplayName: "GLM-5", Description: "Zhipu GLM 5 general model", Created: 1770768000, Thinking: iFlowThinkingSupport},
{ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model", Created: 1752192000},
{ID: "kimi-k2-thinking", DisplayName: "Kimi-K2-Thinking", Description: "Moonshot Kimi K2 thinking model", Created: 1762387200},
{ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Chat", Created: 1764576000},
@@ -828,6 +829,7 @@ func GetIFlowModels() []*ModelInfo {
{ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
{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: "minimax-m2.5", DisplayName: "MiniMax-M2.5", Description: "MiniMax M2.5", Created: 1770825600, 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},
}
@@ -866,7 +868,7 @@ 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-opus-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
"claude-sonnet-4-5": {MaxCompletionTokens: 64000},
"gpt-oss-120b-medium": {},
"tab_flash_lite_preview": {},

View File

@@ -0,0 +1,159 @@
package executor
import (
"context"
"encoding/json"
"io"
"testing"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
func TestAntigravityBuildRequest_SanitizesGeminiToolSchema(t *testing.T) {
body := buildRequestBodyFromPayload(t, "gemini-2.5-pro")
decl := extractFirstFunctionDeclaration(t, body)
if _, ok := decl["parametersJsonSchema"]; ok {
t.Fatalf("parametersJsonSchema should be renamed to parameters")
}
params, ok := decl["parameters"].(map[string]any)
if !ok {
t.Fatalf("parameters missing or invalid type")
}
assertSchemaSanitizedAndPropertyPreserved(t, params)
}
func TestAntigravityBuildRequest_SanitizesAntigravityToolSchema(t *testing.T) {
body := buildRequestBodyFromPayload(t, "claude-opus-4-6")
decl := extractFirstFunctionDeclaration(t, body)
params, ok := decl["parameters"].(map[string]any)
if !ok {
t.Fatalf("parameters missing or invalid type")
}
assertSchemaSanitizedAndPropertyPreserved(t, params)
}
func buildRequestBodyFromPayload(t *testing.T, modelName string) map[string]any {
t.Helper()
executor := &AntigravityExecutor{}
auth := &cliproxyauth.Auth{}
payload := []byte(`{
"request": {
"tools": [
{
"function_declarations": [
{
"name": "tool_1",
"parametersJsonSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "root-schema",
"type": "object",
"properties": {
"$id": {"type": "string"},
"arg": {
"type": "object",
"prefill": "hello",
"properties": {
"mode": {
"type": "string",
"enum": ["a", "b"],
"enumTitles": ["A", "B"]
}
}
}
},
"patternProperties": {
"^x-": {"type": "string"}
}
}
}
]
}
]
}
}`)
req, err := executor.buildRequest(context.Background(), auth, "token", modelName, payload, false, "", "https://example.com")
if err != nil {
t.Fatalf("buildRequest error: %v", err)
}
raw, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("read request body error: %v", err)
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal request body error: %v, body=%s", err, string(raw))
}
return body
}
func extractFirstFunctionDeclaration(t *testing.T, body map[string]any) map[string]any {
t.Helper()
request, ok := body["request"].(map[string]any)
if !ok {
t.Fatalf("request missing or invalid type")
}
tools, ok := request["tools"].([]any)
if !ok || len(tools) == 0 {
t.Fatalf("tools missing or empty")
}
tool, ok := tools[0].(map[string]any)
if !ok {
t.Fatalf("first tool invalid type")
}
decls, ok := tool["function_declarations"].([]any)
if !ok || len(decls) == 0 {
t.Fatalf("function_declarations missing or empty")
}
decl, ok := decls[0].(map[string]any)
if !ok {
t.Fatalf("first function declaration invalid type")
}
return decl
}
func assertSchemaSanitizedAndPropertyPreserved(t *testing.T, params map[string]any) {
t.Helper()
if _, ok := params["$id"]; ok {
t.Fatalf("root $id should be removed from schema")
}
if _, ok := params["patternProperties"]; ok {
t.Fatalf("patternProperties should be removed from schema")
}
props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatalf("properties missing or invalid type")
}
if _, ok := props["$id"]; !ok {
t.Fatalf("property named $id should be preserved")
}
arg, ok := props["arg"].(map[string]any)
if !ok {
t.Fatalf("arg property missing or invalid type")
}
if _, ok := arg["prefill"]; ok {
t.Fatalf("prefill should be removed from nested schema")
}
argProps, ok := arg["properties"].(map[string]any)
if !ok {
t.Fatalf("arg.properties missing or invalid type")
}
mode, ok := argProps["mode"].(map[string]any)
if !ok {
t.Fatalf("mode property missing or invalid type")
}
if _, ok := mode["enumTitles"]; ok {
t.Fatalf("enumTitles should be removed from nested schema")
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/google/uuid"
copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
"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"
@@ -471,9 +472,14 @@ func detectVisionContent(body []byte) bool {
return false
}
// normalizeModel is a no-op as GitHub Copilot accepts model names directly.
// Model mapping should be done at the registry level if needed.
func (e *GitHubCopilotExecutor) normalizeModel(_ string, body []byte) []byte {
// normalizeModel strips the suffix (e.g. "(medium)") from the model name
// before sending to GitHub Copilot, as the upstream API does not accept
// suffixed model identifiers.
func (e *GitHubCopilotExecutor) normalizeModel(model string, body []byte) []byte {
baseModel := thinking.ParseSuffix(model).ModelName
if baseModel != model {
body, _ = sjson.SetBytes(body, "model", baseModel)
}
return body
}

View File

@@ -0,0 +1,54 @@
package executor
import (
"testing"
"github.com/tidwall/gjson"
)
func TestGitHubCopilotNormalizeModel_StripsSuffix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
model string
wantModel string
}{
{
name: "suffix stripped",
model: "claude-opus-4.6(medium)",
wantModel: "claude-opus-4.6",
},
{
name: "no suffix unchanged",
model: "claude-opus-4.6",
wantModel: "claude-opus-4.6",
},
{
name: "different suffix stripped",
model: "gpt-4o(high)",
wantModel: "gpt-4o",
},
{
name: "numeric suffix stripped",
model: "gemini-2.5-pro(8192)",
wantModel: "gemini-2.5-pro",
},
}
e := &GitHubCopilotExecutor{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := []byte(`{"model":"` + tt.model + `","messages":[]}`)
got := e.normalizeModel(tt.model, body)
gotModel := gjson.GetBytes(got, "model").String()
if gotModel != tt.wantModel {
t.Fatalf("normalizeModel() model = %q, want %q", gotModel, tt.wantModel)
}
})
}
}

View File

@@ -519,8 +519,12 @@ func buildKiroPayloadForFormat(body []byte, modelID, profileArn, origin string,
case "openai":
log.Debugf("kiro: using OpenAI payload builder for source format: %s", sourceFormat.String())
return kiroopenai.BuildKiroPayloadFromOpenAI(body, modelID, profileArn, origin, isAgentic, isChatOnly, headers, nil)
case "kiro":
// Body is already in Kiro format — pass through directly (used by callKiroRawAndBuffer)
log.Debugf("kiro: body already in Kiro format, passing through directly")
return body, false
default:
// Default to Claude format (also handles "claude", "kiro", etc.)
// Default to Claude format
log.Debugf("kiro: using Claude payload builder for source format: %s", sourceFormat.String())
return kiroclaude.BuildKiroPayload(body, modelID, profileArn, origin, isAgentic, isChatOnly, headers, nil)
}
@@ -636,6 +640,13 @@ func (e *KiroExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
rateLimiter.WaitForToken(tokenKey)
log.Debugf("kiro: rate limiter cleared for token %s", tokenKey)
// Check for pure web_search request
// Route to MCP endpoint instead of normal Kiro API
if kiroclaude.HasWebSearchTool(req.Payload) {
log.Infof("kiro: detected pure web_search request (non-stream), routing to MCP endpoint")
return e.handleWebSearch(ctx, auth, req, opts, accessToken, profileArn)
}
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
defer reporter.trackFailure(ctx, &err)
@@ -1057,6 +1068,13 @@ func (e *KiroExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
rateLimiter.WaitForToken(tokenKey)
log.Debugf("kiro: stream rate limiter cleared for token %s", tokenKey)
// Check for pure web_search request
// Route to MCP endpoint instead of normal Kiro API
if kiroclaude.HasWebSearchTool(req.Payload) {
log.Infof("kiro: detected pure web_search request, routing to MCP endpoint")
return e.handleWebSearchStream(ctx, auth, req, opts, accessToken, profileArn)
}
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
defer reporter.trackFailure(ctx, &err)
@@ -4096,6 +4114,539 @@ func (e *KiroExecutor) isTokenExpired(accessToken string) bool {
return isExpired
}
// NOTE: Message merging functions moved to internal/translator/kiro/common/message_merge.go
// NOTE: Tool calling support functions moved to internal/translator/kiro/claude/kiro_claude_tools.go
// The executor now uses kiroclaude.* and kirocommon.* functions instead
const maxWebSearchIterations = 5
// handleWebSearchStream handles web_search requests:
// Step 1: tools/list (sync) → fetch/cache tool description
// Step 2+: MCP search → InjectToolResultsClaude → callKiroAndBuffer loop
// Note: We skip the "model decides to search" step because Claude Code already
// decided to use web_search. The Kiro tool description restricts non-coding
// topics, so asking the model again would cause it to refuse valid searches.
func (e *KiroExecutor) handleWebSearchStream(
ctx context.Context,
auth *cliproxyauth.Auth,
req cliproxyexecutor.Request,
opts cliproxyexecutor.Options,
accessToken, profileArn string,
) (<-chan cliproxyexecutor.StreamChunk, error) {
// Extract search query from Claude Code's web_search tool_use
query := kiroclaude.ExtractSearchQuery(req.Payload)
if query == "" {
log.Warnf("kiro/websearch: failed to extract search query, falling back to normal flow")
return e.callKiroDirectStream(ctx, auth, req, opts, accessToken, profileArn)
}
// Build MCP endpoint based on region
region := kiroDefaultRegion
if auth != nil && auth.Metadata != nil {
if r, ok := auth.Metadata["api_region"].(string); ok && r != "" {
region = r
}
}
mcpEndpoint := fmt.Sprintf("https://q.%s.amazonaws.com/mcp", region)
// ── Step 1: tools/list (SYNC) — cache tool description ──
{
tokenKey := getTokenKey(auth)
fp := getGlobalFingerprintManager().GetFingerprint(tokenKey)
var authAttrs map[string]string
if auth != nil {
authAttrs = auth.Attributes
}
kiroclaude.FetchToolDescription(mcpEndpoint, accessToken, newKiroHTTPClientWithPooling(ctx, e.cfg, auth, 30*time.Second), fp, authAttrs)
}
// Create output channel
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
// Send message_start event to client
messageStartEvent := kiroclaude.SseEvent{
Event: "message_start",
Data: map[string]interface{}{
"type": "message_start",
"message": map[string]interface{}{
"id": kiroclaude.GenerateMessageID(),
"type": "message",
"role": "assistant",
"model": req.Model,
"content": []interface{}{},
"stop_reason": nil,
"stop_sequence": nil,
"usage": map[string]interface{}{
"input_tokens": len(req.Payload) / 4,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
},
},
},
}
select {
case <-ctx.Done():
return
case out <- cliproxyexecutor.StreamChunk{Payload: []byte(messageStartEvent.ToSSEString())}:
}
// ── Step 2+: MCP search → InjectToolResultsClaude → callKiroAndBuffer loop ──
contentBlockIndex := 0
currentQuery := query
// Replace web_search tool description with a minimal one that allows re-search.
// The original tools/list description from Kiro restricts non-coding topics,
// but we've already decided to search. We keep the tool so the model can
// request additional searches when results are insufficient.
simplifiedPayload, simplifyErr := kiroclaude.ReplaceWebSearchToolDescription(bytes.Clone(req.Payload))
if simplifyErr != nil {
log.Warnf("kiro/websearch: failed to simplify web_search tool: %v, using original payload", simplifyErr)
simplifiedPayload = bytes.Clone(req.Payload)
}
currentClaudePayload := simplifiedPayload
totalSearches := 0
// Generate toolUseId for the first iteration (Claude Code already decided to search)
currentToolUseId := fmt.Sprintf("srvtoolu_%s", kiroclaude.GenerateToolUseID())
for iteration := 0; iteration < maxWebSearchIterations; iteration++ {
log.Infof("kiro/websearch: search iteration %d/%d — query: %s",
iteration+1, maxWebSearchIterations, currentQuery)
// MCP search
_, mcpRequest := kiroclaude.CreateMcpRequest(currentQuery)
tokenKey := getTokenKey(auth)
fp := getGlobalFingerprintManager().GetFingerprint(tokenKey)
var authAttrs map[string]string
if auth != nil {
authAttrs = auth.Attributes
}
handler := kiroclaude.NewWebSearchHandler(mcpEndpoint, accessToken, newKiroHTTPClientWithPooling(ctx, e.cfg, auth, 30*time.Second), fp, authAttrs)
mcpResponse, mcpErr := handler.CallMcpAPI(mcpRequest)
var searchResults *kiroclaude.WebSearchResults
if mcpErr != nil {
log.Warnf("kiro/websearch: MCP API call failed: %v, continuing with empty results", mcpErr)
} else {
searchResults = kiroclaude.ParseSearchResults(mcpResponse)
}
resultCount := 0
if searchResults != nil {
resultCount = len(searchResults.Results)
}
totalSearches++
log.Infof("kiro/websearch: iteration %d — got %d search results", iteration+1, resultCount)
// Send search indicator events to client
searchEvents := kiroclaude.GenerateSearchIndicatorEvents(currentQuery, currentToolUseId, searchResults, contentBlockIndex)
for _, event := range searchEvents {
select {
case <-ctx.Done():
return
case out <- cliproxyexecutor.StreamChunk{Payload: []byte(event.ToSSEString())}:
}
}
contentBlockIndex += 2
// Inject tool_use + tool_result into Claude payload, then call GAR
var err error
currentClaudePayload, err = kiroclaude.InjectToolResultsClaude(currentClaudePayload, currentToolUseId, currentQuery, searchResults)
if err != nil {
log.Warnf("kiro/websearch: failed to inject tool results: %v", err)
e.sendFallbackText(ctx, out, contentBlockIndex, currentQuery, searchResults)
break
}
// Call GAR with modified Claude payload (full translation pipeline)
modifiedReq := req
modifiedReq.Payload = currentClaudePayload
kiroChunks, kiroErr := e.callKiroAndBuffer(ctx, auth, modifiedReq, opts, accessToken, profileArn)
if kiroErr != nil {
log.Warnf("kiro/websearch: Kiro API failed at iteration %d: %v", iteration+1, kiroErr)
e.sendFallbackText(ctx, out, contentBlockIndex, currentQuery, searchResults)
break
}
// Analyze response
analysis := kiroclaude.AnalyzeBufferedStream(kiroChunks)
log.Infof("kiro/websearch: iteration %d — stop_reason: %s, has_tool_use: %v, query: %s, toolUseId: %s",
iteration+1, analysis.StopReason, analysis.HasWebSearchToolUse, analysis.WebSearchQuery, analysis.WebSearchToolUseId)
if analysis.HasWebSearchToolUse && analysis.WebSearchQuery != "" && iteration+1 < maxWebSearchIterations {
// Model wants another search
filteredChunks := kiroclaude.FilterChunksForClient(kiroChunks, analysis.WebSearchToolUseIndex, contentBlockIndex)
for _, chunk := range filteredChunks {
select {
case <-ctx.Done():
return
case out <- cliproxyexecutor.StreamChunk{Payload: chunk}:
}
}
currentQuery = analysis.WebSearchQuery
currentToolUseId = analysis.WebSearchToolUseId
continue
}
// Model returned final response — stream to client
for _, chunk := range kiroChunks {
if contentBlockIndex > 0 && len(chunk) > 0 {
adjusted, shouldForward := kiroclaude.AdjustSSEChunk(chunk, contentBlockIndex)
if !shouldForward {
continue
}
select {
case <-ctx.Done():
return
case out <- cliproxyexecutor.StreamChunk{Payload: adjusted}:
}
} else {
select {
case <-ctx.Done():
return
case out <- cliproxyexecutor.StreamChunk{Payload: chunk}:
}
}
}
log.Infof("kiro/websearch: completed after %d search iteration(s), total searches: %d", iteration+1, totalSearches)
return
}
log.Warnf("kiro/websearch: reached max iterations (%d), stopping search loop", maxWebSearchIterations)
}()
return out, nil
}
// callKiroAndBuffer calls the Kiro API and buffers all response chunks.
// Returns the buffered chunks for analysis before forwarding to client.
func (e *KiroExecutor) callKiroAndBuffer(
ctx context.Context,
auth *cliproxyauth.Auth,
req cliproxyexecutor.Request,
opts cliproxyexecutor.Options,
accessToken, profileArn string,
) ([][]byte, error) {
from := opts.SourceFormat
to := sdktranslator.FromString("kiro")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
log.Debugf("kiro/websearch GAR request: %d bytes", len(body))
kiroModelID := e.mapModelToKiro(req.Model)
isAgentic, isChatOnly := determineAgenticMode(req.Model)
effectiveProfileArn := getEffectiveProfileArnWithWarning(auth, profileArn)
tokenKey := ""
if auth != nil {
tokenKey = auth.ID
}
kiroStream, err := e.executeStreamWithRetry(
ctx, auth, req, opts, accessToken, effectiveProfileArn,
nil, body, from, nil, "", kiroModelID, isAgentic, isChatOnly, tokenKey,
)
if err != nil {
return nil, err
}
// Buffer all chunks
var chunks [][]byte
for chunk := range kiroStream {
if chunk.Err != nil {
return chunks, chunk.Err
}
if len(chunk.Payload) > 0 {
chunks = append(chunks, bytes.Clone(chunk.Payload))
}
}
log.Debugf("kiro/websearch GAR response: %d chunks buffered", len(chunks))
return chunks, nil
}
// callKiroRawAndBuffer calls the Kiro API with a pre-built Kiro payload (no translation).
// Used in the web search loop where the payload is modified directly in Kiro format.
func (e *KiroExecutor) callKiroRawAndBuffer(
ctx context.Context,
auth *cliproxyauth.Auth,
req cliproxyexecutor.Request,
opts cliproxyexecutor.Options,
accessToken, profileArn string,
kiroBody []byte,
) ([][]byte, error) {
kiroModelID := e.mapModelToKiro(req.Model)
isAgentic, isChatOnly := determineAgenticMode(req.Model)
effectiveProfileArn := getEffectiveProfileArnWithWarning(auth, profileArn)
tokenKey := ""
if auth != nil {
tokenKey = auth.ID
}
log.Debugf("kiro/websearch GAR raw request: %d bytes", len(kiroBody))
kiroFormat := sdktranslator.FromString("kiro")
kiroStream, err := e.executeStreamWithRetry(
ctx, auth, req, opts, accessToken, effectiveProfileArn,
nil, kiroBody, kiroFormat, nil, "", kiroModelID, isAgentic, isChatOnly, tokenKey,
)
if err != nil {
return nil, err
}
// Buffer all chunks
var chunks [][]byte
for chunk := range kiroStream {
if chunk.Err != nil {
return chunks, chunk.Err
}
if len(chunk.Payload) > 0 {
chunks = append(chunks, bytes.Clone(chunk.Payload))
}
}
log.Debugf("kiro/websearch GAR raw response: %d chunks buffered", len(chunks))
return chunks, nil
}
// callKiroDirectStream creates a direct streaming channel to Kiro API without search.
func (e *KiroExecutor) callKiroDirectStream(
ctx context.Context,
auth *cliproxyauth.Auth,
req cliproxyexecutor.Request,
opts cliproxyexecutor.Options,
accessToken, profileArn string,
) (<-chan cliproxyexecutor.StreamChunk, error) {
from := opts.SourceFormat
to := sdktranslator.FromString("kiro")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
kiroModelID := e.mapModelToKiro(req.Model)
isAgentic, isChatOnly := determineAgenticMode(req.Model)
effectiveProfileArn := getEffectiveProfileArnWithWarning(auth, profileArn)
tokenKey := ""
if auth != nil {
tokenKey = auth.ID
}
return e.executeStreamWithRetry(
ctx, auth, req, opts, accessToken, effectiveProfileArn,
nil, body, from, nil, "", kiroModelID, isAgentic, isChatOnly, tokenKey,
)
}
// sendFallbackText sends a simple text response when the Kiro API fails during the search loop.
func (e *KiroExecutor) sendFallbackText(
ctx context.Context,
out chan<- cliproxyexecutor.StreamChunk,
contentBlockIndex int,
query string,
searchResults *kiroclaude.WebSearchResults,
) {
// Generate a simple text summary from search results
summary := kiroclaude.FormatSearchContextPrompt(query, searchResults)
events := []kiroclaude.SseEvent{
{
Event: "content_block_start",
Data: map[string]interface{}{
"type": "content_block_start",
"index": contentBlockIndex,
"content_block": map[string]interface{}{
"type": "text",
"text": "",
},
},
},
{
Event: "content_block_delta",
Data: map[string]interface{}{
"type": "content_block_delta",
"index": contentBlockIndex,
"delta": map[string]interface{}{
"type": "text_delta",
"text": summary,
},
},
},
{
Event: "content_block_stop",
Data: map[string]interface{}{
"type": "content_block_stop",
"index": contentBlockIndex,
},
},
}
for _, event := range events {
select {
case <-ctx.Done():
return
case out <- cliproxyexecutor.StreamChunk{Payload: []byte(event.ToSSEString())}:
}
}
// Send message_delta with end_turn and message_stop
msgDelta := kiroclaude.SseEvent{
Event: "message_delta",
Data: map[string]interface{}{
"type": "message_delta",
"delta": map[string]interface{}{
"stop_reason": "end_turn",
"stop_sequence": nil,
},
"usage": map[string]interface{}{
"output_tokens": len(summary) / 4,
},
},
}
select {
case <-ctx.Done():
return
case out <- cliproxyexecutor.StreamChunk{Payload: []byte(msgDelta.ToSSEString())}:
}
msgStop := kiroclaude.SseEvent{
Event: "message_stop",
Data: map[string]interface{}{
"type": "message_stop",
},
}
select {
case <-ctx.Done():
return
case out <- cliproxyexecutor.StreamChunk{Payload: []byte(msgStop.ToSSEString())}:
}
}
// handleWebSearch handles web_search requests for non-streaming Execute path.
// Performs MCP search synchronously, injects results into the request payload,
// then calls the normal non-streaming Kiro API path which returns a proper
// Claude JSON response (not SSE chunks).
func (e *KiroExecutor) handleWebSearch(
ctx context.Context,
auth *cliproxyauth.Auth,
req cliproxyexecutor.Request,
opts cliproxyexecutor.Options,
accessToken, profileArn string,
) (cliproxyexecutor.Response, error) {
// Extract search query from Claude Code's web_search tool_use
query := kiroclaude.ExtractSearchQuery(req.Payload)
if query == "" {
log.Warnf("kiro/websearch: non-stream: failed to extract search query, falling back to normal Execute")
// Fall through to normal non-streaming path
return e.executeNonStreamFallback(ctx, auth, req, opts, accessToken, profileArn)
}
// Build MCP endpoint based on region
region := kiroDefaultRegion
if auth != nil && auth.Metadata != nil {
if r, ok := auth.Metadata["api_region"].(string); ok && r != "" {
region = r
}
}
mcpEndpoint := fmt.Sprintf("https://q.%s.amazonaws.com/mcp", region)
// Step 1: Fetch/cache tool description (sync)
{
tokenKey := getTokenKey(auth)
fp := getGlobalFingerprintManager().GetFingerprint(tokenKey)
var authAttrs map[string]string
if auth != nil {
authAttrs = auth.Attributes
}
kiroclaude.FetchToolDescription(mcpEndpoint, accessToken, newKiroHTTPClientWithPooling(ctx, e.cfg, auth, 30*time.Second), fp, authAttrs)
}
// Step 2: Perform MCP search
_, mcpRequest := kiroclaude.CreateMcpRequest(query)
tokenKey := getTokenKey(auth)
fp := getGlobalFingerprintManager().GetFingerprint(tokenKey)
var authAttrs map[string]string
if auth != nil {
authAttrs = auth.Attributes
}
handler := kiroclaude.NewWebSearchHandler(mcpEndpoint, accessToken, newKiroHTTPClientWithPooling(ctx, e.cfg, auth, 30*time.Second), fp, authAttrs)
mcpResponse, mcpErr := handler.CallMcpAPI(mcpRequest)
var searchResults *kiroclaude.WebSearchResults
if mcpErr != nil {
log.Warnf("kiro/websearch: non-stream: MCP API call failed: %v, continuing with empty results", mcpErr)
} else {
searchResults = kiroclaude.ParseSearchResults(mcpResponse)
}
resultCount := 0
if searchResults != nil {
resultCount = len(searchResults.Results)
}
log.Infof("kiro/websearch: non-stream: got %d search results for query: %s", resultCount, query)
// Step 3: Inject search tool_use + tool_result into Claude payload
currentToolUseId := fmt.Sprintf("srvtoolu_%s", kiroclaude.GenerateToolUseID())
modifiedPayload, err := kiroclaude.InjectToolResultsClaude(bytes.Clone(req.Payload), currentToolUseId, query, searchResults)
if err != nil {
log.Warnf("kiro/websearch: non-stream: failed to inject tool results: %v, falling back", err)
return e.executeNonStreamFallback(ctx, auth, req, opts, accessToken, profileArn)
}
// Step 4: Call Kiro API via the normal non-streaming path (executeWithRetry)
// This path uses parseEventStream → BuildClaudeResponse → TranslateNonStream
// to produce a proper Claude JSON response
modifiedReq := req
modifiedReq.Payload = modifiedPayload
resp, err := e.executeNonStreamFallback(ctx, auth, modifiedReq, opts, accessToken, profileArn)
if err != nil {
return resp, err
}
// Step 5: Inject server_tool_use + web_search_tool_result into response
// so Claude Code can display "Did X searches in Ys"
indicators := []kiroclaude.SearchIndicator{
{
ToolUseID: currentToolUseId,
Query: query,
Results: searchResults,
},
}
injectedPayload, injErr := kiroclaude.InjectSearchIndicatorsInResponse(resp.Payload, indicators)
if injErr != nil {
log.Warnf("kiro/websearch: non-stream: failed to inject search indicators: %v", injErr)
} else {
resp.Payload = injectedPayload
}
return resp, nil
}
// executeNonStreamFallback runs the standard non-streaming Execute path for a request.
// Used by handleWebSearch after injecting search results, or as a fallback.
func (e *KiroExecutor) executeNonStreamFallback(
ctx context.Context,
auth *cliproxyauth.Auth,
req cliproxyexecutor.Request,
opts cliproxyexecutor.Options,
accessToken, profileArn string,
) (cliproxyexecutor.Response, error) {
from := opts.SourceFormat
to := sdktranslator.FromString("kiro")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
kiroModelID := e.mapModelToKiro(req.Model)
isAgentic, isChatOnly := determineAgenticMode(req.Model)
effectiveProfileArn := getEffectiveProfileArnWithWarning(auth, profileArn)
tokenKey := getTokenKey(auth)
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
var err error
defer reporter.trackFailure(ctx, &err)
resp, err := e.executeWithRetry(ctx, auth, req, opts, accessToken, effectiveProfileArn, nil, body, from, to, reporter, "", kiroModelID, isAgentic, isChatOnly, tokenKey)
return resp, err
}

View File

@@ -344,7 +344,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// Inject interleaved thinking hint when both tools and thinking are active
hasTools := toolDeclCount > 0
thinkingResult := gjson.GetBytes(rawJSON, "thinking")
hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && thinkingResult.Get("type").String() == "enabled"
thinkingType := thinkingResult.Get("type").String()
hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && (thinkingType == "enabled" || thinkingType == "adaptive")
isClaudeThinking := util.IsClaudeThinkingModel(modelName)
if hasTools && hasThinking && isClaudeThinking {
@@ -377,12 +378,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
if t := gjson.GetBytes(rawJSON, "thinking"); enableThoughtTranslate && t.Exists() && t.IsObject() {
if t.Get("type").String() == "enabled" {
switch t.Get("type").String() {
case "enabled":
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
budget := int(b.Int())
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
}
case "adaptive":
// Keep adaptive as a high level sentinel; ApplyThinking resolves it
// to model-specific max capability.
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
}
}
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {

View File

@@ -222,6 +222,10 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
reasoningEffort = effort
}
}
case "adaptive":
// Claude adaptive means "enable with max capacity"; keep it as highest level
// and let ApplyThinking normalize per target model capability.
reasoningEffort = string(thinking.LevelXHigh)
case "disabled":
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
reasoningEffort = effort

View File

@@ -27,6 +27,9 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte,
rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p")
rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier")
// Delete the user field as it is not supported by the Codex upstream.
rawJSON, _ = sjson.DeleteBytes(rawJSON, "user")
// Convert role "system" to "developer" in input array to comply with Codex API requirements.
rawJSON = convertSystemRoleToDeveloper(rawJSON)

View File

@@ -263,3 +263,20 @@ func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) {
t.Errorf("Expected third role 'assistant', got '%s'", thirdRole.String())
}
}
func TestUserFieldDeletion(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"user": "test-user",
"input": [{"role": "user", "content": "Hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Verify user field is deleted
userField := gjson.Get(outputStr, "user")
if userField.Exists() {
t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw)
}
}

View File

@@ -173,12 +173,18 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
if t.Get("type").String() == "enabled" {
switch t.Get("type").String() {
case "enabled":
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
budget := int(b.Int())
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
}
case "adaptive":
// Keep adaptive as a high level sentinel; ApplyThinking resolves it
// to model-specific max capability.
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
}
}
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {

View File

@@ -154,12 +154,18 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
// Translator only does format conversion, ApplyThinking handles model capability validation.
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
if t.Get("type").String() == "enabled" {
switch t.Get("type").String() {
case "enabled":
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
budget := int(b.Int())
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true)
}
case "adaptive":
// Keep adaptive as a high level sentinel; ApplyThinking resolves it
// to model-specific max capability.
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true)
}
}
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {

View File

@@ -117,19 +117,29 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
switch itemType {
case "message":
if strings.EqualFold(itemRole, "system") {
if contentArray := item.Get("content"); contentArray.Exists() && contentArray.IsArray() {
var builder strings.Builder
contentArray.ForEach(func(_, contentItem gjson.Result) bool {
text := contentItem.Get("text").String()
if builder.Len() > 0 && text != "" {
builder.WriteByte('\n')
}
builder.WriteString(text)
return true
})
if !gjson.Get(out, "system_instruction").Exists() {
systemInstr := `{"parts":[{"text":""}]}`
systemInstr, _ = sjson.Set(systemInstr, "parts.0.text", builder.String())
if contentArray := item.Get("content"); contentArray.Exists() {
systemInstr := ""
if systemInstructionResult := gjson.Get(out, "system_instruction"); systemInstructionResult.Exists() {
systemInstr = systemInstructionResult.Raw
} else {
systemInstr = `{"parts":[]}`
}
if contentArray.IsArray() {
contentArray.ForEach(func(_, contentItem gjson.Result) bool {
part := `{"text":""}`
text := contentItem.Get("text").String()
part, _ = sjson.Set(part, "text", text)
systemInstr, _ = sjson.SetRaw(systemInstr, "parts.-1", part)
return true
})
} else if contentArray.Type == gjson.String {
part := `{"text":""}`
part, _ = sjson.Set(part, "text", contentArray.String())
systemInstr, _ = sjson.SetRaw(systemInstr, "parts.-1", part)
}
if systemInstr != `{"parts":[]}` {
out, _ = sjson.SetRaw(out, "system_instruction", systemInstr)
}
}
@@ -236,8 +246,22 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
})
flush()
}
} else if contentArray.Type == gjson.String {
effRole := "user"
if itemRole != "" {
switch strings.ToLower(itemRole) {
case "assistant", "model":
effRole = "model"
default:
effRole = strings.ToLower(itemRole)
}
}
one := `{"role":"","parts":[{"text":""}]}`
one, _ = sjson.Set(one, "role", effRole)
one, _ = sjson.Set(one, "parts.0.text", contentArray.String())
out, _ = sjson.SetRaw(out, "contents.-1", one)
}
case "function_call":
// Handle function calls - convert to model message with functionCall
name := item.Get("name").String()

View File

@@ -17,6 +17,9 @@ import (
"github.com/tidwall/gjson"
)
// remoteWebSearchDescription is a minimal fallback for when dynamic fetch from MCP tools/list hasn't completed yet.
const remoteWebSearchDescription = "WebSearch looks up information outside the model's training data. Supports multiple queries to gather comprehensive information."
// Kiro API request structs - field order determines JSON key order
// KiroPayload is the top-level request structure for Kiro API
@@ -219,26 +222,7 @@ func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isA
}
// Convert Claude tools to Kiro format
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)")
}
kiroTools := convertClaudeToolsToKiro(tools)
// Thinking mode implementation:
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
@@ -527,27 +511,15 @@ func ensureKiroInputSchema(parameters interface{}) interface{} {
}
}
// 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) {
// convertClaudeToolsToKiro converts Claude tools to Kiro format
func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
var kiroTools []KiroToolWrapper
hasWebSearch := false
if !tools.IsArray() {
return kiroTools, hasWebSearch
return kiroTools
}
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{}
@@ -569,6 +541,18 @@ func convertClaudeToolsToKiro(tools gjson.Result) ([]KiroToolWrapper, bool) {
log.Debugf("kiro: tool '%s' has empty description, using default: %s", name, description)
}
// Rename web_search → remote_web_search for Kiro API compatibility
if name == "web_search" {
name = "remote_web_search"
// Prefer dynamically fetched description, fall back to hardcoded constant
if cached := GetWebSearchDescription(); cached != "" {
description = cached
} else {
description = remoteWebSearchDescription
}
log.Debugf("kiro: renamed tool web_search → remote_web_search")
}
// Truncate long descriptions (individual tool limit)
if len(description) > kirocommon.KiroMaxToolDescLen {
truncLen := kirocommon.KiroMaxToolDescLen - 30
@@ -591,7 +575,7 @@ func convertClaudeToolsToKiro(tools gjson.Result) ([]KiroToolWrapper, bool) {
// This prevents 500 errors when Claude Code sends too many tools
kiroTools = compressToolsIfNeeded(kiroTools)
return kiroTools, hasWebSearch
return kiroTools
}
// processMessages processes Claude messages and builds Kiro history
@@ -602,6 +586,17 @@ func processMessages(messages gjson.Result, modelID, origin string) ([]KiroHisto
// Merge adjacent messages with the same role
messagesArray := kirocommon.MergeAdjacentMessages(messages.Array())
// FIX: Kiro API requires history to start with a user message.
// Some clients (e.g., OpenClaw) send conversations starting with an assistant message,
// which is valid for the Claude API but causes "Improperly formed request" on Kiro.
// Prepend a placeholder user message so the history alternation is correct.
if len(messagesArray) > 0 && messagesArray[0].Get("role").String() == "assistant" {
placeholder := `{"role":"user","content":"."}`
messagesArray = append([]gjson.Result{gjson.Parse(placeholder)}, messagesArray...)
log.Infof("kiro: messages started with assistant role, prepended placeholder user message for Kiro API compatibility")
}
for i, msg := range messagesArray {
role := msg.Get("role").String()
isLastMessage := i == len(messagesArray)-1
@@ -654,6 +649,57 @@ func processMessages(messages gjson.Result, modelID, origin string) ([]KiroHisto
}
}
// POST-PROCESSING: Remove orphaned tool_results that have no matching tool_use
// in any assistant message. This happens when Claude Code compaction truncates
// the conversation and removes the assistant message containing the tool_use,
// but keeps the user message with the corresponding tool_result.
// Without this fix, Kiro API returns "Improperly formed request".
validToolUseIDs := make(map[string]bool)
for _, h := range history {
if h.AssistantResponseMessage != nil {
for _, tu := range h.AssistantResponseMessage.ToolUses {
validToolUseIDs[tu.ToolUseID] = true
}
}
}
// Filter orphaned tool results from history user messages
for i, h := range history {
if h.UserInputMessage != nil && h.UserInputMessage.UserInputMessageContext != nil {
ctx := h.UserInputMessage.UserInputMessageContext
if len(ctx.ToolResults) > 0 {
filtered := make([]KiroToolResult, 0, len(ctx.ToolResults))
for _, tr := range ctx.ToolResults {
if validToolUseIDs[tr.ToolUseID] {
filtered = append(filtered, tr)
} else {
log.Debugf("kiro: dropping orphaned tool_result in history[%d]: toolUseId=%s (no matching tool_use)", i, tr.ToolUseID)
}
}
ctx.ToolResults = filtered
if len(ctx.ToolResults) == 0 && len(ctx.Tools) == 0 {
h.UserInputMessage.UserInputMessageContext = nil
}
}
}
}
// Filter orphaned tool results from current message
if len(currentToolResults) > 0 {
filtered := make([]KiroToolResult, 0, len(currentToolResults))
for _, tr := range currentToolResults {
if validToolUseIDs[tr.ToolUseID] {
filtered = append(filtered, tr)
} else {
log.Debugf("kiro: dropping orphaned tool_result in currentMessage: toolUseId=%s (no matching tool_use)", tr.ToolUseID)
}
}
if len(filtered) != len(currentToolResults) {
log.Infof("kiro: dropped %d orphaned tool_result(s) from currentMessage (compaction artifact)", len(currentToolResults)-len(filtered))
}
currentToolResults = filtered
}
return history, currentUserMsg, currentToolResults
}
@@ -876,6 +922,11 @@ func BuildAssistantMessageStruct(msg gjson.Result) KiroAssistantResponseMessage
})
}
// Rename web_search → remote_web_search to match convertClaudeToolsToKiro
if toolName == "web_search" {
toolName = "remote_web_search"
}
toolUses = append(toolUses, KiroToolUse{
ToolUseID: toolUseID,
Name: toolName,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,270 @@
// Package claude provides web search handler for Kiro translator.
// This file implements the MCP API call and response handling.
package claude
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
log "github.com/sirupsen/logrus"
)
// Cached web_search tool description fetched from MCP tools/list.
// Uses atomic.Pointer[sync.Once] for lock-free reads with retry-on-failure:
// - sync.Once prevents race conditions and deduplicates concurrent calls
// - On failure, a fresh sync.Once is swapped in to allow retry on next call
// - On success, sync.Once stays "done" forever — zero overhead for subsequent calls
var (
cachedToolDescription atomic.Value // stores string
toolDescOnce atomic.Pointer[sync.Once]
fallbackFpOnce sync.Once
fallbackFp *kiroauth.Fingerprint
)
func init() {
toolDescOnce.Store(&sync.Once{})
}
// FetchToolDescription calls MCP tools/list to get the web_search tool description
// and caches it. Safe to call concurrently — only one goroutine fetches at a time.
// If the fetch fails, subsequent calls will retry. On success, no further fetches occur.
// The httpClient parameter allows reusing a shared pooled HTTP client.
func FetchToolDescription(mcpEndpoint, authToken string, httpClient *http.Client, fp *kiroauth.Fingerprint, authAttrs map[string]string) {
toolDescOnce.Load().Do(func() {
handler := NewWebSearchHandler(mcpEndpoint, authToken, httpClient, fp, authAttrs)
reqBody := []byte(`{"id":"tools_list","jsonrpc":"2.0","method":"tools/list"}`)
log.Debugf("kiro/websearch MCP tools/list request: %d bytes", len(reqBody))
req, err := http.NewRequest("POST", mcpEndpoint, bytes.NewReader(reqBody))
if err != nil {
log.Warnf("kiro/websearch: failed to create tools/list request: %v", err)
toolDescOnce.Store(&sync.Once{}) // allow retry
return
}
// Reuse same headers as CallMcpAPI
handler.setMcpHeaders(req)
resp, err := handler.HTTPClient.Do(req)
if err != nil {
log.Warnf("kiro/websearch: tools/list request failed: %v", err)
toolDescOnce.Store(&sync.Once{}) // allow retry
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
log.Warnf("kiro/websearch: tools/list returned status %d", resp.StatusCode)
toolDescOnce.Store(&sync.Once{}) // allow retry
return
}
log.Debugf("kiro/websearch MCP tools/list response: [%d] %d bytes", resp.StatusCode, len(body))
// Parse: {"result":{"tools":[{"name":"web_search","description":"..."}]}}
var result struct {
Result *struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil || result.Result == nil {
log.Warnf("kiro/websearch: failed to parse tools/list response")
toolDescOnce.Store(&sync.Once{}) // allow retry
return
}
for _, tool := range result.Result.Tools {
if tool.Name == "web_search" && tool.Description != "" {
cachedToolDescription.Store(tool.Description)
log.Infof("kiro/websearch: cached web_search description from tools/list (%d bytes)", len(tool.Description))
return // success — sync.Once stays "done", no more fetches
}
}
// web_search tool not found in response
toolDescOnce.Store(&sync.Once{}) // allow retry
})
}
// GetWebSearchDescription returns the cached web_search tool description,
// or empty string if not yet fetched. Lock-free via atomic.Value.
func GetWebSearchDescription() string {
if v := cachedToolDescription.Load(); v != nil {
return v.(string)
}
return ""
}
// WebSearchHandler handles web search requests via Kiro MCP API
type WebSearchHandler struct {
McpEndpoint string
HTTPClient *http.Client
AuthToken string
Fingerprint *kiroauth.Fingerprint // optional, for dynamic headers
AuthAttrs map[string]string // optional, for custom headers from auth.Attributes
}
// NewWebSearchHandler creates a new WebSearchHandler.
// If httpClient is nil, a default client with 30s timeout is used.
// If fingerprint is nil, a random one-off fingerprint is generated.
// Pass a shared pooled client (e.g. from getKiroPooledHTTPClient) for connection reuse.
func NewWebSearchHandler(mcpEndpoint, authToken string, httpClient *http.Client, fp *kiroauth.Fingerprint, authAttrs map[string]string) *WebSearchHandler {
if httpClient == nil {
httpClient = &http.Client{
Timeout: 30 * time.Second,
}
}
if fp == nil {
// Use a shared fallback fingerprint for callers without token context
fallbackFpOnce.Do(func() {
mgr := kiroauth.NewFingerprintManager()
fallbackFp = mgr.GetFingerprint("mcp-fallback")
})
fp = fallbackFp
}
return &WebSearchHandler{
McpEndpoint: mcpEndpoint,
HTTPClient: httpClient,
AuthToken: authToken,
Fingerprint: fp,
AuthAttrs: authAttrs,
}
}
// setMcpHeaders sets standard MCP API headers on the request,
// aligned with the GAR request pattern in kiro_executor.go.
func (h *WebSearchHandler) setMcpHeaders(req *http.Request) {
fp := h.Fingerprint
// 1. Content-Type & Accept (aligned with GAR)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
// 2. Kiro-specific headers (aligned with GAR)
req.Header.Set("x-amzn-kiro-agent-mode", "vibe")
req.Header.Set("x-amzn-codewhisperer-optout", "true")
// 3. Dynamic fingerprint headers
req.Header.Set("User-Agent", fp.BuildUserAgent())
req.Header.Set("X-Amz-User-Agent", fp.BuildAmzUserAgent())
// 4. AWS SDK identifiers (casing aligned with GAR)
req.Header.Set("Amz-Sdk-Request", "attempt=1; max=3")
req.Header.Set("Amz-Sdk-Invocation-Id", uuid.New().String())
// 5. Authentication
req.Header.Set("Authorization", "Bearer "+h.AuthToken)
// 6. Custom headers from auth attributes
util.ApplyCustomHeadersFromAttrs(req, h.AuthAttrs)
}
// mcpMaxRetries is the maximum number of retries for MCP API calls.
const mcpMaxRetries = 2
// CallMcpAPI calls the Kiro MCP API with the given request.
// Includes retry logic with exponential backoff for retryable errors,
// aligned with the GAR request retry pattern.
func (h *WebSearchHandler) CallMcpAPI(request *McpRequest) (*McpResponse, error) {
requestBody, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal MCP request: %w", err)
}
log.Debugf("kiro/websearch MCP request → %s (%d bytes)", h.McpEndpoint, len(requestBody))
var lastErr error
for attempt := 0; attempt <= mcpMaxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<attempt) * time.Second
if backoff > 10*time.Second {
backoff = 10 * time.Second
}
log.Warnf("kiro/websearch: MCP retry %d/%d after %v (last error: %v)", attempt, mcpMaxRetries, backoff, lastErr)
time.Sleep(backoff)
}
req, err := http.NewRequest("POST", h.McpEndpoint, bytes.NewReader(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
h.setMcpHeaders(req)
resp, err := h.HTTPClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("MCP API request failed: %w", err)
continue // network error → retry
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("failed to read MCP response: %w", err)
continue // read error → retry
}
log.Debugf("kiro/websearch MCP response ← [%d] (%d bytes)", resp.StatusCode, len(body))
// Retryable HTTP status codes (aligned with GAR: 502, 503, 504)
if resp.StatusCode >= 502 && resp.StatusCode <= 504 {
lastErr = fmt.Errorf("MCP API returned retryable status %d: %s", resp.StatusCode, string(body))
continue
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("MCP API returned status %d: %s", resp.StatusCode, string(body))
}
var mcpResponse McpResponse
if err := json.Unmarshal(body, &mcpResponse); err != nil {
return nil, fmt.Errorf("failed to parse MCP response: %w", err)
}
if mcpResponse.Error != nil {
code := -1
if mcpResponse.Error.Code != nil {
code = *mcpResponse.Error.Code
}
msg := "Unknown error"
if mcpResponse.Error.Message != nil {
msg = *mcpResponse.Error.Message
}
return nil, fmt.Errorf("MCP error %d: %s", code, msg)
}
return &mcpResponse, nil
}
return nil, lastErr
}
// ParseSearchResults extracts WebSearchResults from MCP response
func ParseSearchResults(response *McpResponse) *WebSearchResults {
if response == nil || response.Result == nil || len(response.Result.Content) == 0 {
return nil
}
content := response.Result.Content[0]
if content.ContentType != "text" {
return nil
}
var results WebSearchResults
if err := json.Unmarshal([]byte(content.Text), &results); err != nil {
log.Warnf("kiro/websearch: failed to parse search results: %v", err)
return nil
}
return &results
}

View File

@@ -36,8 +36,14 @@ func MergeAdjacentMessages(messages []gjson.Result) []gjson.Result {
if currentRole == lastRole {
// Merge content from current message into last message
mergedContent := mergeMessageContent(lastMsg, msg)
// Create a new merged message JSON
mergedMsg := createMergedMessage(lastRole, mergedContent)
var mergedToolCalls []interface{}
if currentRole == "assistant" {
// Preserve assistant tool_calls when adjacent assistant messages are merged.
mergedToolCalls = mergeToolCalls(lastMsg.Get("tool_calls"), msg.Get("tool_calls"))
}
// Create a new merged message JSON.
mergedMsg := createMergedMessage(lastRole, mergedContent, mergedToolCalls)
merged[len(merged)-1] = gjson.Parse(mergedMsg)
} else {
merged = append(merged, msg)
@@ -121,12 +127,34 @@ func blockToMap(block gjson.Result) map[string]interface{} {
return result
}
// createMergedMessage creates a JSON string for a merged message
func createMergedMessage(role string, content string) string {
// createMergedMessage creates a JSON string for a merged message.
// toolCalls is optional and only emitted for assistant role.
func createMergedMessage(role string, content string, toolCalls []interface{}) string {
msg := map[string]interface{}{
"role": role,
"content": json.RawMessage(content),
}
if role == "assistant" && len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
result, _ := json.Marshal(msg)
return string(result)
}
}
// mergeToolCalls combines tool_calls from two assistant messages while preserving order.
func mergeToolCalls(tc1, tc2 gjson.Result) []interface{} {
var merged []interface{}
if tc1.IsArray() {
for _, tc := range tc1.Array() {
merged = append(merged, tc.Value())
}
}
if tc2.IsArray() {
for _, tc := range tc2.Array() {
merged = append(merged, tc.Value())
}
}
return merged
}

View File

@@ -0,0 +1,106 @@
package common
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
func parseMessages(t *testing.T, raw string) []gjson.Result {
t.Helper()
parsed := gjson.Parse(raw)
if !parsed.IsArray() {
t.Fatalf("expected JSON array, got: %s", raw)
}
return parsed.Array()
}
func TestMergeAdjacentMessages_AssistantMergePreservesToolCalls(t *testing.T) {
messages := parseMessages(t, `[
{"role":"assistant","content":"part1"},
{
"role":"assistant",
"content":"part2",
"tool_calls":[
{
"id":"call_1",
"type":"function",
"function":{"name":"Read","arguments":"{}"}
}
]
},
{"role":"tool","tool_call_id":"call_1","content":"ok"}
]`)
merged := MergeAdjacentMessages(messages)
if len(merged) != 2 {
t.Fatalf("expected 2 messages after merge, got %d", len(merged))
}
assistant := merged[0]
if assistant.Get("role").String() != "assistant" {
t.Fatalf("expected first message role assistant, got %q", assistant.Get("role").String())
}
toolCalls := assistant.Get("tool_calls")
if !toolCalls.IsArray() || len(toolCalls.Array()) != 1 {
t.Fatalf("expected assistant.tool_calls length 1, got: %s", toolCalls.Raw)
}
if toolCalls.Array()[0].Get("id").String() != "call_1" {
t.Fatalf("expected tool call id call_1, got %q", toolCalls.Array()[0].Get("id").String())
}
contentRaw := assistant.Get("content").Raw
if !strings.Contains(contentRaw, "part1") || !strings.Contains(contentRaw, "part2") {
t.Fatalf("expected merged content to contain both parts, got: %s", contentRaw)
}
if merged[1].Get("role").String() != "tool" {
t.Fatalf("expected second message role tool, got %q", merged[1].Get("role").String())
}
}
func TestMergeAdjacentMessages_AssistantMergeCombinesMultipleToolCalls(t *testing.T) {
messages := parseMessages(t, `[
{
"role":"assistant",
"content":"first",
"tool_calls":[
{"id":"call_1","type":"function","function":{"name":"Read","arguments":"{}"}}
]
},
{
"role":"assistant",
"content":"second",
"tool_calls":[
{"id":"call_2","type":"function","function":{"name":"Write","arguments":"{}"}}
]
}
]`)
merged := MergeAdjacentMessages(messages)
if len(merged) != 1 {
t.Fatalf("expected 1 message after merge, got %d", len(merged))
}
toolCalls := merged[0].Get("tool_calls").Array()
if len(toolCalls) != 2 {
t.Fatalf("expected 2 merged tool calls, got %d", len(toolCalls))
}
if toolCalls[0].Get("id").String() != "call_1" || toolCalls[1].Get("id").String() != "call_2" {
t.Fatalf("unexpected merged tool call ids: %q, %q", toolCalls[0].Get("id").String(), toolCalls[1].Get("id").String())
}
}
func TestMergeAdjacentMessages_ToolMessagesRemainUnmerged(t *testing.T) {
messages := parseMessages(t, `[
{"role":"tool","tool_call_id":"call_1","content":"r1"},
{"role":"tool","tool_call_id":"call_2","content":"r2"}
]`)
merged := MergeAdjacentMessages(messages)
if len(merged) != 2 {
t.Fatalf("expected tool messages to remain separate, got %d", len(merged))
}
}

View File

@@ -75,6 +75,10 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
}
case "adaptive":
// Claude adaptive means "enable with max capacity"; keep it as highest level
// and let ApplyThinking normalize per target model capability.
out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh))
case "disabled":
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)

View File

@@ -428,8 +428,9 @@ func flattenTypeArrays(jsonStr string) string {
func removeUnsupportedKeywords(jsonStr string) string {
keywords := append(unsupportedConstraints,
"$schema", "$defs", "definitions", "const", "$ref", "additionalProperties",
"propertyNames", // Gemini doesn't support property name validation
"$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",
"propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
"enumTitles", "prefill", // Claude/OpenCode schema metadata fields unsupported by Gemini
)
deletePaths := make([]string, 0)

View File

@@ -870,6 +870,57 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {
}
}
func TestCleanJSONSchemaForGemini_RemovesGeminiUnsupportedMetadataFields(t *testing.T) {
input := `{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "root-schema",
"type": "object",
"properties": {
"payload": {
"type": "object",
"prefill": "hello",
"properties": {
"mode": {
"type": "string",
"enum": ["a", "b"],
"enumTitles": ["A", "B"]
}
},
"patternProperties": {
"^x-": {"type": "string"}
}
},
"$id": {
"type": "string",
"description": "property name should not be removed"
}
}
}`
expected := `{
"type": "object",
"properties": {
"payload": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["a", "b"],
"description": "Allowed: a, b"
}
}
},
"$id": {
"type": "string",
"description": "property name should not be removed"
}
}
}`
result := CleanJSONSchemaForGemini(input)
compareJSON(t, expected, result)
}
func TestRemoveExtensionFields(t *testing.T) {
tests := []struct {
name string

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -92,6 +93,9 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e
status = coreauth.StatusDisabled
}
// Read per-account excluded models from the OAuth JSON file
perAccountExcluded := extractExcludedModelsFromMetadata(metadata)
a := &coreauth.Auth{
ID: id,
Provider: provider,
@@ -108,11 +112,23 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e
CreatedAt: now,
UpdatedAt: now,
}
ApplyAuthExcludedModelsMeta(a, cfg, nil, "oauth")
// Read priority from auth file
if rawPriority, ok := metadata["priority"]; ok {
switch v := rawPriority.(type) {
case float64:
a.Attributes["priority"] = strconv.Itoa(int(v))
case string:
priority := strings.TrimSpace(v)
if _, errAtoi := strconv.Atoi(priority); errAtoi == nil {
a.Attributes["priority"] = priority
}
}
}
ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth")
if provider == "gemini-cli" {
if virtuals := SynthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 {
for _, v := range virtuals {
ApplyAuthExcludedModelsMeta(v, cfg, nil, "oauth")
ApplyAuthExcludedModelsMeta(v, cfg, perAccountExcluded, "oauth")
}
out = append(out, a)
out = append(out, virtuals...)
@@ -167,6 +183,10 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an
if authPath != "" {
attrs["path"] = authPath
}
// Propagate priority from primary auth to virtual auths
if priorityVal, hasPriority := primary.Attributes["priority"]; hasPriority && priorityVal != "" {
attrs["priority"] = priorityVal
}
metadataCopy := map[string]any{
"email": email,
"project_id": projectID,
@@ -239,3 +259,40 @@ func buildGeminiVirtualID(baseID, projectID string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_")
return fmt.Sprintf("%s::%s", baseID, replacer.Replace(project))
}
// extractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata.
// Supports both "excluded_models" and "excluded-models" keys, and accepts both []string and []interface{}.
func extractExcludedModelsFromMetadata(metadata map[string]any) []string {
if metadata == nil {
return nil
}
// Try both key formats
raw, ok := metadata["excluded_models"]
if !ok {
raw, ok = metadata["excluded-models"]
}
if !ok || raw == nil {
return nil
}
var stringSlice []string
switch v := raw.(type) {
case []string:
stringSlice = v
case []interface{}:
stringSlice = make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
stringSlice = append(stringSlice, s)
}
}
default:
return nil
}
result := make([]string, 0, len(stringSlice))
for _, s := range stringSlice {
if trimmed := strings.TrimSpace(s); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}

View File

@@ -297,6 +297,117 @@ func TestFileSynthesizer_Synthesize_PrefixValidation(t *testing.T) {
}
}
func TestFileSynthesizer_Synthesize_PriorityParsing(t *testing.T) {
tests := []struct {
name string
priority any
want string
hasValue bool
}{
{
name: "string with spaces",
priority: " 10 ",
want: "10",
hasValue: true,
},
{
name: "number",
priority: 8,
want: "8",
hasValue: true,
},
{
name: "invalid string",
priority: "1x",
hasValue: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "claude",
"priority": tt.priority,
}
data, _ := json.Marshal(authData)
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
if errWriteFile != nil {
t.Fatalf("failed to write auth file: %v", errWriteFile)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, errSynthesize := synth.Synthesize(ctx)
if errSynthesize != nil {
t.Fatalf("unexpected error: %v", errSynthesize)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
value, ok := auths[0].Attributes["priority"]
if tt.hasValue {
if !ok {
t.Fatal("expected priority attribute to be set")
}
if value != tt.want {
t.Fatalf("expected priority %q, got %q", tt.want, value)
}
return
}
if ok {
t.Fatalf("expected priority attribute to be absent, got %q", value)
}
})
}
}
func TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "claude",
"excluded_models": []string{"custom-model", "MODEL-B"},
}
data, _ := json.Marshal(authData)
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
if errWriteFile != nil {
t.Fatalf("failed to write auth file: %v", errWriteFile)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{
OAuthExcludedModels: map[string][]string{
"claude": {"shared", "model-b"},
},
},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, errSynthesize := synth.Synthesize(ctx)
if errSynthesize != nil {
t.Fatalf("unexpected error: %v", errSynthesize)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
got := auths[0].Attributes["excluded_models"]
want := "custom-model,model-b,shared"
if got != want {
t.Fatalf("expected excluded_models %q, got %q", want, got)
}
}
func TestSynthesizeGeminiVirtualAuths_NilInputs(t *testing.T) {
now := time.Now()
@@ -533,6 +644,7 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) {
"type": "gemini",
"email": "multi@example.com",
"project_id": "project-a, project-b, project-c",
"priority": " 10 ",
}
data, _ := json.Marshal(authData)
err := os.WriteFile(filepath.Join(tempDir, "gemini-multi.json"), data, 0644)
@@ -565,6 +677,9 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) {
if primary.Status != coreauth.StatusDisabled {
t.Errorf("expected primary status disabled, got %s", primary.Status)
}
if gotPriority := primary.Attributes["priority"]; gotPriority != "10" {
t.Errorf("expected primary priority 10, got %q", gotPriority)
}
// Remaining auths should be virtuals
for i := 1; i < 4; i++ {
@@ -575,6 +690,9 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) {
if v.Attributes["gemini_virtual_parent"] != primary.ID {
t.Errorf("expected virtual %d parent to be %s, got %s", i, primary.ID, v.Attributes["gemini_virtual_parent"])
}
if gotPriority := v.Attributes["priority"]; gotPriority != "10" {
t.Errorf("expected virtual %d priority 10, got %q", i, gotPriority)
}
}
}

View File

@@ -53,6 +53,8 @@ func (g *StableIDGenerator) Next(kind string, parts ...string) (string, string)
// ApplyAuthExcludedModelsMeta applies excluded models metadata to an auth entry.
// It computes a hash of excluded models and sets the auth_kind attribute.
// For OAuth entries, perKey (from the JSON file's excluded-models field) is merged
// with the global oauth-excluded-models config for the provider.
func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) {
if auth == nil || cfg == nil {
return
@@ -72,9 +74,13 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey
}
if authKindKey == "apikey" {
add(perKey)
} else if cfg.OAuthExcludedModels != nil {
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
add(cfg.OAuthExcludedModels[providerKey])
} else {
// For OAuth: merge per-account excluded models with global provider-level exclusions
add(perKey)
if cfg.OAuthExcludedModels != nil {
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
add(cfg.OAuthExcludedModels[providerKey])
}
}
combined := make([]string, 0, len(seen))
for k := range seen {
@@ -88,6 +94,10 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey
if hash != "" {
auth.Attributes["excluded_models_hash"] = hash
}
// Store the combined excluded models list so that routing can read it at runtime
if len(combined) > 0 {
auth.Attributes["excluded_models"] = strings.Join(combined, ",")
}
if authKind != "" {
auth.Attributes["auth_kind"] = authKind
}

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
@@ -200,6 +201,30 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) {
}
}
func TestApplyAuthExcludedModelsMeta_OAuthMergeWritesCombinedModels(t *testing.T) {
auth := &coreauth.Auth{
Provider: "claude",
Attributes: make(map[string]string),
}
cfg := &config.Config{
OAuthExcludedModels: map[string][]string{
"claude": {"global-a", "shared"},
},
}
ApplyAuthExcludedModelsMeta(auth, cfg, []string{"per", "SHARED"}, "oauth")
const wantCombined = "global-a,per,shared"
if gotCombined := auth.Attributes["excluded_models"]; gotCombined != wantCombined {
t.Fatalf("expected excluded_models=%q, got %q", wantCombined, gotCombined)
}
expectedHash := diff.ComputeExcludedModelsHash([]string{"global-a", "per", "shared"})
if gotHash := auth.Attributes["excluded_models_hash"]; gotHash != expectedHash {
t.Fatalf("expected excluded_models_hash=%q, got %q", expectedHash, gotHash)
}
}
func TestAddConfigHeadersToAttrs(t *testing.T) {
tests := []struct {
name string

View File

@@ -79,6 +79,24 @@ func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {
input: "gemini-2.5-pro(none)",
want: "gemini-2.5-pro-exp-03-25(none)",
},
{
name: "github-copilot suffix preserved",
aliases: map[string][]internalconfig.OAuthModelAlias{
"github-copilot": {{Name: "claude-opus-4.6", Alias: "opus"}},
},
channel: "github-copilot",
input: "opus(medium)",
want: "claude-opus-4.6(medium)",
},
{
name: "github-copilot no suffix",
aliases: map[string][]internalconfig.OAuthModelAlias{
"github-copilot": {{Name: "claude-opus-4.6", Alias: "opus"}},
},
channel: "github-copilot",
input: "opus",
want: "claude-opus-4.6",
},
{
name: "kimi suffix preserved",
aliases: map[string][]internalconfig.OAuthModelAlias{
@@ -174,6 +192,8 @@ func createAuthForChannel(channel string) *Auth {
return &Auth{Provider: "kimi"}
case "kiro":
return &Auth{Provider: "kiro"}
case "github-copilot":
return &Auth{Provider: "github-copilot"}
default:
return &Auth{Provider: channel}
}
@@ -187,6 +207,22 @@ func TestOAuthModelAliasChannel_Kimi(t *testing.T) {
}
}
func TestOAuthModelAliasChannel_GitHubCopilot(t *testing.T) {
t.Parallel()
if got := OAuthModelAliasChannel("github-copilot", ""); got != "github-copilot" {
t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "github-copilot")
}
}
func TestOAuthModelAliasChannel_Kiro(t *testing.T) {
t.Parallel()
if got := OAuthModelAliasChannel("kiro", ""); got != "kiro" {
t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "kiro")
}
}
func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {
t.Parallel()

View File

@@ -767,6 +767,13 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
provider = "openai-compatibility"
}
excluded := s.oauthExcludedModels(provider, authKind)
// The synthesizer pre-merges per-account and global exclusions into the "excluded_models" attribute.
// If this attribute is present, it represents the complete list of exclusions and overrides the global config.
if a.Attributes != nil {
if val, ok := a.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
excluded = strings.Split(val, ",")
}
}
var models []*ModelInfo
switch provider {
case "gemini":

View File

@@ -0,0 +1,65 @@
package cliproxy
import (
"strings"
"testing"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
)
func TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T) {
service := &Service{
cfg: &config.Config{
OAuthExcludedModels: map[string][]string{
"gemini-cli": {"gemini-2.5-pro"},
},
},
}
auth := &coreauth.Auth{
ID: "auth-gemini-cli",
Provider: "gemini-cli",
Status: coreauth.StatusActive,
Attributes: map[string]string{
"auth_kind": "oauth",
"excluded_models": "gemini-2.5-flash",
},
}
registry := GlobalModelRegistry()
registry.UnregisterClient(auth.ID)
t.Cleanup(func() {
registry.UnregisterClient(auth.ID)
})
service.registerModelsForAuth(auth)
models := registry.GetAvailableModelsByProvider("gemini-cli")
if len(models) == 0 {
t.Fatal("expected gemini-cli models to be registered")
}
for _, model := range models {
if model == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if strings.EqualFold(modelID, "gemini-2.5-flash") {
t.Fatalf("expected model %q to be excluded by auth attribute", modelID)
}
}
seenGlobalExcluded := false
for _, model := range models {
if model == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(model.ID), "gemini-2.5-pro") {
seenGlobalExcluded = true
break
}
}
if !seenGlobalExcluded {
t.Fatal("expected global excluded model to be present when attribute override is set")
}
}

View File

@@ -2590,6 +2590,135 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
runThinkingTests(t, cases)
}
// TestThinkingE2EClaudeAdaptive_Body tests Claude thinking.type=adaptive extended body-only cases.
// These cases validate that adaptive means "thinking enabled without explicit budget", and
// cross-protocol conversion should resolve to target-model maximum thinking capability.
func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
reg := registry.GetGlobalRegistry()
uid := fmt.Sprintf("thinking-e2e-claude-adaptive-%d", time.Now().UnixNano())
reg.RegisterClient(uid, "test", getTestModels())
defer reg.UnregisterClient(uid)
cases := []thinkingTestCase{
// A1: Claude adaptive to OpenAI level model -> highest supported level
{
name: "A1",
from: "claude",
to: "openai",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "reasoning_effort",
expectValue: "high",
expectErr: false,
},
// A2: Claude adaptive to Gemini level subset model -> highest supported level
{
name: "A2",
from: "claude",
to: "gemini",
model: "level-subset-model",
inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "generationConfig.thinkingConfig.thinkingLevel",
expectValue: "high",
includeThoughts: "true",
expectErr: false,
},
// A3: Claude adaptive to Gemini budget model -> max budget
{
name: "A3",
from: "claude",
to: "gemini",
model: "gemini-budget-model",
inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "generationConfig.thinkingConfig.thinkingBudget",
expectValue: "20000",
includeThoughts: "true",
expectErr: false,
},
// A4: Claude adaptive to Gemini mixed model -> highest supported level
{
name: "A4",
from: "claude",
to: "gemini",
model: "gemini-mixed-model",
inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "generationConfig.thinkingConfig.thinkingLevel",
expectValue: "high",
includeThoughts: "true",
expectErr: false,
},
// A5: Claude adaptive passthrough for same protocol
{
name: "A5",
from: "claude",
to: "claude",
model: "claude-budget-model",
inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "thinking.type",
expectValue: "adaptive",
expectErr: false,
},
// A6: Claude adaptive to Antigravity budget model -> max budget
{
name: "A6",
from: "claude",
to: "antigravity",
model: "antigravity-budget-model",
inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "request.generationConfig.thinkingConfig.thinkingBudget",
expectValue: "20000",
includeThoughts: "true",
expectErr: false,
},
// A7: Claude adaptive to iFlow GLM -> enabled boolean
{
name: "A7",
from: "claude",
to: "iflow",
model: "glm-test",
inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "chat_template_kwargs.enable_thinking",
expectValue: "true",
expectErr: false,
},
// A8: Claude adaptive to iFlow MiniMax -> enabled boolean
{
name: "A8",
from: "claude",
to: "iflow",
model: "minimax-test",
inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "reasoning_split",
expectValue: "true",
expectErr: false,
},
// A9: Claude adaptive to Codex level model -> highest supported level
{
name: "A9",
from: "claude",
to: "codex",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "reasoning.effort",
expectValue: "high",
expectErr: false,
},
// A10: Claude adaptive on non-thinking model should still be stripped
{
name: "A10",
from: "claude",
to: "openai",
model: "no-thinking-model",
inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "",
expectErr: false,
},
}
runThinkingTests(t, cases)
}
// getTestModels returns the shared model definitions for E2E tests.
func getTestModels() []*registry.ModelInfo {
return []*registry.ModelInfo{