History

Merge branch 'main' into plus

Luis Pater committed 10 months ago

Commit e85c9d92 parents cb56cb2 5c9512975 files · +15212 −302Browse files

Unified · Split

75 files
  1. .github/workflows/docker-image.yml +1 −1
  2. .github/workflows/release.yaml +2 −1
  3. .gitignore +2 −0
  4. .goreleaser.yml +4 −4
  5. Dockerfile +3 −3
  6. README.md +10 −112
  7. README_CN.md +10 −119
  8. cmd/server/main.go +56 −0
  9. config.example.yaml +15 −0
  10. docker-compose.yml +2 −2
  11. go.mod +2 −1
  12. go.sum +3 −2
  13. internal/api/handlers/management/auth_files.go +318 −0
  14. internal/api/handlers/management/config_basic.go +2 −2
  15. internal/api/handlers/management/oauth_sessions.go +8 −1
  16. internal/api/modules/amp/proxy.go +34 −3
  17. internal/api/modules/amp/response_rewriter.go +58 −2
  18. internal/api/server.go +21 −0
  19. internal/auth/claude/oauth_server.go +11 −0
  20. internal/auth/codex/oauth_server.go +11 −0
  21. internal/auth/copilot/copilot_auth.go +225 −0
  22. internal/auth/copilot/errors.go +187 −0
  23. internal/auth/copilot/oauth.go +255 −0
  24. internal/auth/copilot/token.go +93 −0
  25. internal/auth/iflow/iflow_auth.go +17 −5
  26. internal/auth/kiro/aws.go +305 −0
  27. internal/auth/kiro/aws_auth.go +314 −0
  28. internal/auth/kiro/aws_test.go +161 −0
  29. internal/auth/kiro/codewhisperer_client.go +166 −0
  30. internal/auth/kiro/oauth.go +303 −0
  31. internal/auth/kiro/protocol_handler.go +725 −0
  32. internal/auth/kiro/social_auth.go +403 −0
  33. internal/auth/kiro/sso_oidc.go +1371 −0
  34. internal/auth/kiro/token.go +72 −0
  35. internal/browser/browser.go +416 −14
  36. internal/cmd/auth_manager.go +3 −1
  37. internal/cmd/github_copilot_login.go +44 −0
  38. internal/cmd/kiro_login.go +208 −0
  39. internal/config/config.go +62 −0
  40. internal/constant/constant.go +3 −0
  41. internal/logging/global_logger.go +1 −0
  42. internal/registry/model_definitions.go +361 −0
  43. internal/registry/model_registry.go +15 −1
  44. internal/runtime/executor/cache_helpers.go +30 −2
  45. internal/runtime/executor/codex_executor.go +2 −2
  46. internal/runtime/executor/github_copilot_executor.go +361 −0
  47. internal/runtime/executor/kiro_executor.go +3302 −0
  48. internal/runtime/executor/proxy_helpers.go +44 −5
  49. internal/runtime/executor/token_helpers.go +276 −15
  50. internal/translator/claude/openai/chat-completions/claude_openai_response.go +4 −0
  51. internal/translator/init.go +3 −0
  52. internal/translator/kiro/claude/init.go +20 −0
  53. internal/translator/kiro/claude/kiro_claude.go +21 −0
  54. internal/translator/kiro/claude/kiro_claude_request.go +809 −0
  55. internal/translator/kiro/claude/kiro_claude_response.go +204 −0
  56. internal/translator/kiro/claude/kiro_claude_stream.go +186 −0
  57. internal/translator/kiro/claude/kiro_claude_tools.go +522 −0
  58. internal/translator/kiro/common/constants.go +75 −0
  59. internal/translator/kiro/common/message_merge.go +132 −0
  60. internal/translator/kiro/common/utils.go +16 −0
  61. internal/translator/kiro/openai/init.go +20 −0
  62. internal/translator/kiro/openai/kiro_openai.go +371 −0
  63. internal/translator/kiro/openai/kiro_openai_request.go +863 −0
  64. internal/translator/kiro/openai/kiro_openai_request_test.go +386 −0
  65. internal/translator/kiro/openai/kiro_openai_response.go +277 −0
  66. internal/translator/kiro/openai/kiro_openai_stream.go +212 −0
  67. internal/watcher/events.go +67 −1
  68. internal/watcher/synthesizer/config.go +97 −0
  69. sdk/api/handlers/handlers.go +2 −1
  70. sdk/auth/github_copilot.go +129 −0
  71. sdk/auth/kiro.go +470 −0
  72. sdk/auth/manager.go +13 −0
  73. sdk/auth/refresh_registry.go +2 −0
  74. sdk/cliproxy/auth/conductor.go +4 −2
  75. sdk/cliproxy/service.go +9 −0
.github/workflows/docker-image.yml+1−1
@@ -7,7 +7,7 @@ on: · 6 unmodified lines
77
88 env:
99 APP_NAME: CLIProxyAPI
10− DOCKERHUB_REPO: eceasy/cli-proxy-api
10+ DOCKERHUB_REPO: eceasy/cli-proxy-api-plus
1111
1212 jobs:
1313 docker:
.github/workflows/release.yaml+2−1
@@ -23,7 +23,8 @@ jobs: · 22 unmodified lines
2323 cache: true
2424 - name: Generate Build Metadata
2525 run: |
26− echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
26+ VERSION=$(git describe --tags --always --dirty)
27+ echo "VERSION=${VERSION}" >> $GITHUB_ENV
2728 echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
2829 echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
2930 - uses: goreleaser/goreleaser-action@v4
.gitignore+2−0
@@ -1,5 +1,6 @@
11 # Binaries
22 cli-proxy-api
3+cliproxy
34 *.exe
45
56 # Configuration
@@ -44,6 +45,7 @@ GEMINI.md · 38 unmodified lines
4445 .bmad/*
4546 _bmad/*
4647 _bmad-output/*
48+.mcp/cache/
4749
4850 # macOS
4951 .DS_Store
.goreleaser.yml+4−4
@@ -1,5 +1,5 @@
11 builds:
2− - id: "cli-proxy-api"
2+ - id: "cli-proxy-api-plus"
33 env:
44 - CGO_ENABLED=0
55 goos:
@@ -10,11 +10,11 @@ builds: · 4 unmodified lines
1010 - amd64
1111 - arm64
1212 main: ./cmd/server/
13− binary: cli-proxy-api
13+ binary: cli-proxy-api-plus
1414 ldflags:
15− - -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
15+ - -s -w -X 'main.Version={{.Version}}-plus' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
1616 archives:
17− - id: "cli-proxy-api"
17+ - id: "cli-proxy-api-plus"
1818 format: tar.gz
1919 format_overrides:
2020 - goos: windows
Dockerfile+3−3
@@ -12,7 +12,7 @@ ARG VERSION=dev · 11 unmodified lines
1212 ARG COMMIT=none
1313 ARG BUILD_DATE=unknown
1414
15−RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
15+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}-plus' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPIPlus ./cmd/server/
1616
1717 FROM alpine:3.22.0
1818
@@ -20,7 +20,7 @@ RUN apk add --no-cache tzdata · 1 unmodified line
2020
2121 RUN mkdir /CLIProxyAPI
2222
23−COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
23+COPY --from=builder ./app/CLIProxyAPIPlus /CLIProxyAPI/CLIProxyAPIPlus
2424
2525 COPY config.example.yaml /CLIProxyAPI/config.example.yaml
2626
@@ -32,4 +32,4 @@ ENV TZ=Asia/Shanghai · 5 unmodified lines
3232
3333 RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
3434
35−CMD ["./CLIProxyAPI"]
35+CMD ["./CLIProxyAPIPlus"]
README.md+10−112
@@ -1,125 +1,23 @@
1−# CLI Proxy API
1+# CLIProxyAPI Plus
22
3−English | [中文](README_CN.md)
3+English | [Chinese](README_CN.md)
44
5−A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
5+This is the Plus version of [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI), adding support for third-party providers on top of the mainline project.
66
7−It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
7+All third-party provider support is maintained by community contributors; CLIProxyAPI does not provide technical support. Please contact the corresponding community maintainer if you need assistance.
88
9−So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
9+The Plus release stays in lockstep with the mainline features.
1010
11−## Sponsor
11+## Differences from the Mainline
1212
13−[![z.ai](https://assets.router-for.me/english-4.7.png)](https://z.ai/subscribe?ic=8JVLJQFSKB)
14−
15−This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
16−
17−GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.7 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
18−
19−Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
20−
21−---
22−
23−<table>
24−<tbody>
25−<tr>
26−<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
27−<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.</td>
28−</tr>
29−<tr>
30−<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
31−<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td>
32−</tr>
33−</tbody>
34−</table>
35−
36−## Overview
37−
38−- OpenAI/Gemini/Claude compatible API endpoints for CLI models
39−- OpenAI Codex support (GPT models) via OAuth login
40−- Claude Code support via OAuth login
41−- Qwen Code support via OAuth login
42−- iFlow support via OAuth login
43−- Amp CLI and IDE extensions support with provider routing
44−- Streaming and non-streaming responses
45−- Function calling/tools support
46−- Multimodal input support (text and images)
47−- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
48−- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
49−- Generative Language API Key support
50−- AI Studio Build multi-account load balancing
51−- Gemini CLI multi-account load balancing
52−- Claude Code multi-account load balancing
53−- Qwen Code multi-account load balancing
54−- iFlow multi-account load balancing
55−- OpenAI Codex multi-account load balancing
56−- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
57−- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
58−
59−## Getting Started
60−
61−CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
62−
63−## Management API
64−
65−see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
66−
67−## Amp CLI Support
68−
69−CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
70−
71−- Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
72−- Management proxy for OAuth authentication and account features
73−- Smart model fallback with automatic routing
74−- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
75−- Security-first design with localhost-only management endpoints
76−
77−**→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
78−
79−## SDK Docs
80−
81−- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
82−- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
83−- Access: [docs/sdk-access.md](docs/sdk-access.md)
84−- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
85−- Custom Provider Example: `examples/custom-provider`
13+- Added GitHub Copilot support (OAuth login), provided by [em4go](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)
14+- Added Kiro (AWS CodeWhisperer) support (OAuth login), provided by [fuko2935](https://github.com/fuko2935/CLIProxyAPI/tree/feature/kiro-integration), [Ravens2121](https://github.com/Ravens2121/CLIProxyAPIPlus/)
8615
8716 ## Contributing
8817
89−Contributions are welcome! Please feel free to submit a Pull Request.
90−
91−1. Fork the repository
92−2. Create your feature branch (`git checkout -b feature/amazing-feature`)
93−3. Commit your changes (`git commit -m 'Add some amazing feature'`)
94−4. Push to the branch (`git push origin feature/amazing-feature`)
95−5. Open a Pull Request
96−
97−## Who is with us?
98−
99−Those projects are based on CLIProxyAPI:
100−
101−### [vibeproxy](https://github.com/automazeio/vibeproxy)
102−
103−Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
104−
105−### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
106−
107−Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
108−
109−### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
110−
111−CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
112−
113−### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
114−
115−Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed.
116−
117−### [Quotio](https://github.com/nguyenphutrong/quotio)
118−
119−Native macOS menu bar app that unifies Claude, Gemini, OpenAI, Qwen, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
18+This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected.
12019
121−> [!NOTE]
122−> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
20+If you need to submit any non-third-party provider changes, please open them against the mainline repository.
12321
12422 ## License
12523
README_CN.md+10−119
@@ -1,133 +1,24 @@
1−# CLI 代理 API
1+# CLIProxyAPI Plus
22
33 [English](README.md) | 中文
44
5−一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
5+这是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的 Plus 版本,在原有基础上增加了第三方供应商的支持。
66
7−现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。
7+所有的第三方供应商支持都由第三方社区维护者提供,CLIProxyAPI 不提供技术支持。如需取得支持,请与对应的社区维护者联系。
88
9−您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。
9+该 Plus 版本的主线功能与主线功能强制同步。
1010
11−## 赞助商
11+## 与主线版本版本差异
1212
13−[![bigmodel.cn](https://assets.router-for.me/chinese-4.7.png)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
14−
15−本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
16−
17−GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7,为开发者提供顶尖的编码体验。
18−
19−智谱AI为本软件提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
20−
21−---
22−
23−<table>
24−<tbody>
25−<tr>
26−<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
27−<td>感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。</td>
28−</tr>
29−<tr>
30−<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
31−<td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td>
32−</tr>
33−</tbody>
34−</table>
35−
36−
37−## 功能特性
38−
39−- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
40−- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
41−- 新增 Claude Code 支持(OAuth 登录)
42−- 新增 Qwen Code 支持(OAuth 登录)
43−- 新增 iFlow 支持(OAuth 登录)
44−- 支持流式与非流式响应
45−- 函数调用/工具支持
46−- 多模态输入(文本、图片)
47−- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
48−- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
49−- 支持 Gemini AIStudio API 密钥
50−- 支持 AI Studio Build 多账户轮询
51−- 支持 Gemini CLI 多账户轮询
52−- 支持 Claude Code 多账户轮询
53−- 支持 Qwen Code 多账户轮询
54−- 支持 iFlow 多账户轮询
55−- 支持 OpenAI Codex 多账户轮询
56−- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
57−- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
58−
59−## 新手入门
60−
61−CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
62−
63−## 管理 API 文档
64−
65−请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
66−
67−## Amp CLI 支持
68−
69−CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
70−
71−- 提供商路由别名,兼容 Amp 的 API 路径模式(`/api/provider/{provider}/v1...`)
72−- 管理代理,处理 OAuth 认证和账号功能
73−- 智能模型回退与自动路由
74−- 以安全为先的设计,管理端点仅限 localhost
75−
76−**→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**
77−
78−## SDK 文档
79−
80−- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
81−- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
82−- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
83−- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
84−- 自定义 Provider 示例:`examples/custom-provider`
13+- 新增 GitHub Copilot 支持(OAuth 登录),由[em4go](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)提供
14+- 新增 Kiro (AWS CodeWhisperer) 支持 (OAuth 登录), 由[fuko2935](https://github.com/fuko2935/CLIProxyAPI/tree/feature/kiro-integration)、[Ravens2121](https://github.com/Ravens2121/CLIProxyAPIPlus/)提供
8515
8616 ## 贡献
8717
88−欢迎贡献!请随时提交 Pull Request。
18+该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。
8919
90−1. Fork 仓库
91−2. 创建您的功能分支(`git checkout -b feature/amazing-feature`)
92−3. 提交您的更改(`git commit -m 'Add some amazing feature'`)
93−4. 推送到分支(`git push origin feature/amazing-feature`)
94−5. 打开 Pull Request
95−
96−## 谁与我们在一起?
97−
98−这些项目基于 CLIProxyAPI:
99−
100−### [vibeproxy](https://github.com/automazeio/vibeproxy)
101−
102−一个原生 macOS 菜单栏应用,让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具,无需 API 密钥。
103−
104−### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
105−
106−一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
107−
108−### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
109−
110−CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
111−
112−### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
113−
114−基于 macOS 平台的原生 CLIProxyAPI GUI:配置供应商、模型映射以及OAuth端点,无需 API 密钥。
115−
116−### [Quotio](https://github.com/nguyenphutrong/quotio)
117−
118−原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI、Qwen 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
119−
120−> [!NOTE]
121−> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
20+如果需要提交任何非第三方供应商支持的 Pull Request,请提交到主线版本。
12221
12322 ## 许可证
12423
125−此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
126−
127−## 写给所有中国网友的
128−
129−QQ 群:188637136
130−
131−或
132−
133−Telegram 群:https://t.me/CLIProxyAPI
24+此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
cmd/server/main.go+56−0
@@ -47,6 +47,19 @@ func init() { · 46 unmodified lines
4747 buildinfo.BuildDate = BuildDate
4848 }
4949
50+// setKiroIncognitoMode sets the incognito browser mode for Kiro authentication.
51+// Kiro defaults to incognito mode for multi-account support.
52+// Users can explicitly override with --incognito or --no-incognito flags.
53+func setKiroIncognitoMode(cfg *config.Config, useIncognito, noIncognito bool) {
54+ if useIncognito {
55+ cfg.IncognitoBrowser = true
56+ } else if noIncognito {
57+ cfg.IncognitoBrowser = false
58+ } else {
59+ cfg.IncognitoBrowser = true // Kiro default
60+ }
61+}
62+
5063 // main is the entry point of the application.
5164 // It parses command-line flags, loads configuration, and starts the appropriate
5265 // service based on the provided flags (login, codex-login, or server mode).
@@ -62,10 +75,18 @@ func main() { · 9 unmodified lines
6275 var iflowCookie bool
6376 var noBrowser bool
6477 var antigravityLogin bool
78+ var kiroLogin bool
79+ var kiroGoogleLogin bool
80+ var kiroAWSLogin bool
81+ var kiroAWSAuthCode bool
82+ var kiroImport bool
83+ var githubCopilotLogin bool
6584 var projectID string
6685 var vertexImport string
6786 var configPath string
6887 var password string
88+ var noIncognito bool
89+ var useIncognito bool
6990
7091 // Define command-line flags for different operation modes.
7192 flag.BoolVar(&login, "login", false, "Login Google Account")
@@ -75,7 +96,15 @@ func main() { · 3 unmodified lines
7596 flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
7697 flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
7798 flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
99+ flag.BoolVar(&useIncognito, "incognito", false, "Open browser in incognito/private mode for OAuth (useful for multiple accounts)")
100+ flag.BoolVar(&noIncognito, "no-incognito", false, "Force disable incognito mode (uses existing browser session)")
78101 flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
102+ flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
103+ flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
104+ flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
105+ flag.BoolVar(&kiroAWSAuthCode, "kiro-aws-authcode", false, "Login to Kiro using AWS Builder ID (authorization code flow, better UX)")
106+ flag.BoolVar(&kiroImport, "kiro-import", false, "Import Kiro token from Kiro IDE (~/.aws/sso/cache/kiro-auth-token.json)")
107+ flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow")
79108 flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
80109 flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
81110 flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
@@ -453,6 +482,9 @@ func main() { · 371 unmodified lines
453482 } else if antigravityLogin {
454483 // Handle Antigravity login
455484 cmd.DoAntigravityLogin(cfg, options)
485+ } else if githubCopilotLogin {
486+ // Handle GitHub Copilot login
487+ cmd.DoGitHubCopilotLogin(cfg, options)
456488 } else if codexLogin {
457489 // Handle Codex login
458490 cmd.DoCodexLogin(cfg, options)
@@ -465,6 +497,30 @@ func main() { · 6 unmodified lines
465497 cmd.DoIFlowLogin(cfg, options)
466498 } else if iflowCookie {
467499 cmd.DoIFlowCookieAuth(cfg, options)
500+ } else if kiroLogin {
501+ // For Kiro auth, default to incognito mode for multi-account support
502+ // Users can explicitly override with --no-incognito
503+ // Note: This config mutation is safe - auth commands exit after completion
504+ // and don't share config with StartService (which is in the else branch)
505+ setKiroIncognitoMode(cfg, useIncognito, noIncognito)
506+ cmd.DoKiroLogin(cfg, options)
507+ } else if kiroGoogleLogin {
508+ // For Kiro auth, default to incognito mode for multi-account support
509+ // Users can explicitly override with --no-incognito
510+ // Note: This config mutation is safe - auth commands exit after completion
511+ setKiroIncognitoMode(cfg, useIncognito, noIncognito)
512+ cmd.DoKiroGoogleLogin(cfg, options)
513+ } else if kiroAWSLogin {
514+ // For Kiro auth, default to incognito mode for multi-account support
515+ // Users can explicitly override with --no-incognito
516+ setKiroIncognitoMode(cfg, useIncognito, noIncognito)
517+ cmd.DoKiroAWSLogin(cfg, options)
518+ } else if kiroAWSAuthCode {
519+ // For Kiro auth with authorization code flow (better UX)
520+ setKiroIncognitoMode(cfg, useIncognito, noIncognito)
521+ cmd.DoKiroAWSAuthCodeLogin(cfg, options)
522+ } else if kiroImport {
523+ cmd.DoKiroImport(cfg, options)
468524 } else {
469525 // In cloud deploy mode without config file, just wait for shutdown signals
470526 if isCloudDeploy && !configFileExists {
config.example.yaml+15−0
@@ -43,6 +43,11 @@ debug: false · 42 unmodified lines
4343 # When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
4444 commercial-mode: false
4545
46+# Open OAuth URLs in incognito/private browser mode.
47+# Useful when you want to login with a different account without logging out from your current session.
48+# Default: false (but Kiro auth defaults to true for multi-account support)
49+incognito-browser: true
50+
4651 # When true, write application logs to rotating files instead of stdout
4752 logging-to-file: false
4853
@@ -135,6 +140,16 @@ ws-auth: false · 86 unmodified lines
135140 # - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
136141 # - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
137142
143+# Kiro (AWS CodeWhisperer) configuration
144+# Note: Kiro API currently only operates in us-east-1 region
145+#kiro:
146+# - token-file: "~/.aws/sso/cache/kiro-auth-token.json" # path to Kiro token file
147+# agent-task-type: "" # optional: "vibe" or empty (API default)
148+# - access-token: "aoaAAAAA..." # or provide tokens directly
149+# refresh-token: "aorAAAAA..."
150+# profile-arn: "arn:aws:codewhisperer:us-east-1:..."
151+# proxy-url: "socks5://proxy.example.com:1080" # optional: proxy override
152+
138153 # OpenAI compatibility providers
139154 # openai-compatibility:
140155 # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
docker-compose.yml+2−2
@@ -1,6 +1,6 @@
11 services:
22 cli-proxy-api:
3− image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
3+ image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api-plus:latest}
44 pull_policy: always
55 build:
66 context: .
@@ -9,7 +9,7 @@ services: · 2 unmodified lines
99 VERSION: ${VERSION:-dev}
1010 COMMIT: ${COMMIT:-none}
1111 BUILD_DATE: ${BUILD_DATE:-unknown}
12− container_name: cli-proxy-api
12+ container_name: cli-proxy-api-plus
1313 # env_file:
1414 # - .env
1515 environment:
go.mod+2−1
@@ -13,14 +13,15 @@ require ( · 12 unmodified lines
1313 github.com/joho/godotenv v1.5.1
1414 github.com/klauspost/compress v1.17.4
1515 github.com/minio/minio-go/v7 v7.0.66
16+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1617 github.com/sirupsen/logrus v1.9.3
17− github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
1818 github.com/tidwall/gjson v1.18.0
1919 github.com/tidwall/sjson v1.2.5
2020 github.com/tiktoken-go/tokenizer v0.7.0
2121 golang.org/x/crypto v0.45.0
2222 golang.org/x/net v0.47.0
2323 golang.org/x/oauth2 v0.30.0
24+ golang.org/x/term v0.37.0
2425 gopkg.in/natefinch/lumberjack.v2 v2.2.1
2526 gopkg.in/yaml.v3 v3.0.1
2627 )
go.sum+3−2
@@ -116,6 +116,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 · 115 unmodified lines
116116 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
117117 github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
118118 github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
119+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
120+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
119121 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
120122 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
121123 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -126,8 +128,6 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= · 4 unmodified lines
126128 github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
127129 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
128130 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
129−github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
130−github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
131131 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
132132 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
133133 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -169,6 +169,7 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl · 35 unmodified lines
169169 golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
170170 golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
171171 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
172+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
172173 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
173174 golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
174175 golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
internal/api/handlers/management/auth_files.go+318−0
@@ -3,6 +3,9 @@ package management · 2 unmodified lines
33 import (
44 "bytes"
55 "context"
6+ "crypto/rand"
7+ "crypto/sha256"
8+ "encoding/base64"
69 "encoding/json"
710 "errors"
811 "fmt"
@@ -23,6 +26,7 @@ import ( · 14 unmodified lines
2326 "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
2427 geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
2528 iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
29+ kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
2630 "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
2731 "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
2832 "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
@@ -2281,8 +2285,322 @@ func (h *Handler) GetAuthStatus(c *gin.Context) { · 2252 unmodified lines
22812285 return
22822286 }
22832287 if status != "" {
2288+ if strings.HasPrefix(status, "device_code|") {
2289+ parts := strings.SplitN(status, "|", 3)
2290+ if len(parts) == 3 {
2291+ c.JSON(http.StatusOK, gin.H{
2292+ "status": "device_code",
2293+ "verification_url": parts[1],
2294+ "user_code": parts[2],
2295+ })
2296+ return
2297+ }
2298+ }
2299+ if strings.HasPrefix(status, "auth_url|") {
2300+ authURL := strings.TrimPrefix(status, "auth_url|")
2301+ c.JSON(http.StatusOK, gin.H{
2302+ "status": "auth_url",
2303+ "url": authURL,
2304+ })
2305+ return
2306+ }
22842307 c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
22852308 return
22862309 }
22872310 c.JSON(http.StatusOK, gin.H{"status": "wait"})
22882311 }
2312+
2313+const kiroCallbackPort = 9876
2314+
2315+func (h *Handler) RequestKiroToken(c *gin.Context) {
2316+ ctx := context.Background()
2317+
2318+ // Get the login method from query parameter (default: aws for device code flow)
2319+ method := strings.ToLower(strings.TrimSpace(c.Query("method")))
2320+ if method == "" {
2321+ method = "aws"
2322+ }
2323+
2324+ fmt.Println("Initializing Kiro authentication...")
2325+
2326+ state := fmt.Sprintf("kiro-%d", time.Now().UnixNano())
2327+
2328+ switch method {
2329+ case "aws", "builder-id":
2330+ RegisterOAuthSession(state, "kiro")
2331+
2332+ // AWS Builder ID uses device code flow (no callback needed)
2333+ go func() {
2334+ ssoClient := kiroauth.NewSSOOIDCClient(h.cfg)
2335+
2336+ // Step 1: Register client
2337+ fmt.Println("Registering client...")
2338+ regResp, errRegister := ssoClient.RegisterClient(ctx)
2339+ if errRegister != nil {
2340+ log.Errorf("Failed to register client: %v", errRegister)
2341+ SetOAuthSessionError(state, "Failed to register client")
2342+ return
2343+ }
2344+
2345+ // Step 2: Start device authorization
2346+ fmt.Println("Starting device authorization...")
2347+ authResp, errAuth := ssoClient.StartDeviceAuthorization(ctx, regResp.ClientID, regResp.ClientSecret)
2348+ if errAuth != nil {
2349+ log.Errorf("Failed to start device auth: %v", errAuth)
2350+ SetOAuthSessionError(state, "Failed to start device authorization")
2351+ return
2352+ }
2353+
2354+ // Store the verification URL for the frontend to display.
2355+ // Using "|" as separator because URLs contain ":".
2356+ SetOAuthSessionError(state, "device_code|"+authResp.VerificationURIComplete+"|"+authResp.UserCode)
2357+
2358+ // Step 3: Poll for token
2359+ fmt.Println("Waiting for authorization...")
2360+ interval := 5 * time.Second
2361+ if authResp.Interval > 0 {
2362+ interval = time.Duration(authResp.Interval) * time.Second
2363+ }
2364+ deadline := time.Now().Add(time.Duration(authResp.ExpiresIn) * time.Second)
2365+
2366+ for time.Now().Before(deadline) {
2367+ select {
2368+ case <-ctx.Done():
2369+ SetOAuthSessionError(state, "Authorization cancelled")
2370+ return
2371+ case <-time.After(interval):
2372+ tokenResp, errToken := ssoClient.CreateToken(ctx, regResp.ClientID, regResp.ClientSecret, authResp.DeviceCode)
2373+ if errToken != nil {
2374+ errStr := errToken.Error()
2375+ if strings.Contains(errStr, "authorization_pending") {
2376+ continue
2377+ }
2378+ if strings.Contains(errStr, "slow_down") {
2379+ interval += 5 * time.Second
2380+ continue
2381+ }
2382+ log.Errorf("Token creation failed: %v", errToken)
2383+ SetOAuthSessionError(state, "Token creation failed")
2384+ return
2385+ }
2386+
2387+ // Success! Save the token
2388+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
2389+ email := kiroauth.ExtractEmailFromJWT(tokenResp.AccessToken)
2390+
2391+ idPart := kiroauth.SanitizeEmailForFilename(email)
2392+ if idPart == "" {
2393+ idPart = fmt.Sprintf("%d", time.Now().UnixNano()%100000)
2394+ }
2395+
2396+ now := time.Now()
2397+ fileName := fmt.Sprintf("kiro-aws-%s.json", idPart)
2398+
2399+ record := &coreauth.Auth{
2400+ ID: fileName,
2401+ Provider: "kiro",
2402+ FileName: fileName,
2403+ Metadata: map[string]any{
2404+ "type": "kiro",
2405+ "access_token": tokenResp.AccessToken,
2406+ "refresh_token": tokenResp.RefreshToken,
2407+ "expires_at": expiresAt.Format(time.RFC3339),
2408+ "auth_method": "builder-id",
2409+ "provider": "AWS",
2410+ "client_id": regResp.ClientID,
2411+ "client_secret": regResp.ClientSecret,
2412+ "email": email,
2413+ "last_refresh": now.Format(time.RFC3339),
2414+ },
2415+ }
2416+
2417+ savedPath, errSave := h.saveTokenRecord(ctx, record)
2418+ if errSave != nil {
2419+ log.Errorf("Failed to save authentication tokens: %v", errSave)
2420+ SetOAuthSessionError(state, "Failed to save authentication tokens")
2421+ return
2422+ }
2423+
2424+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
2425+ if email != "" {
2426+ fmt.Printf("Authenticated as: %s\n", email)
2427+ }
2428+ CompleteOAuthSession(state)
2429+ return
2430+ }
2431+ }
2432+
2433+ SetOAuthSessionError(state, "Authorization timed out")
2434+ }()
2435+
2436+ // Return immediately with the state for polling
2437+ c.JSON(http.StatusOK, gin.H{"status": "ok", "state": state, "method": "device_code"})
2438+
2439+ case "google", "github":
2440+ RegisterOAuthSession(state, "kiro")
2441+
2442+ // Social auth uses protocol handler - for WEB UI we use a callback forwarder
2443+ provider := "Google"
2444+ if method == "github" {
2445+ provider = "Github"
2446+ }
2447+
2448+ isWebUI := isWebUIRequest(c)
2449+ if isWebUI {
2450+ targetURL, errTarget := h.managementCallbackURL("/kiro/callback")
2451+ if errTarget != nil {
2452+ log.WithError(errTarget).Error("failed to compute kiro callback target")
2453+ c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
2454+ return
2455+ }
2456+ if _, errStart := startCallbackForwarder(kiroCallbackPort, "kiro", targetURL); errStart != nil {
2457+ log.WithError(errStart).Error("failed to start kiro callback forwarder")
2458+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
2459+ return
2460+ }
2461+ }
2462+
2463+ go func() {
2464+ if isWebUI {
2465+ defer stopCallbackForwarder(kiroCallbackPort)
2466+ }
2467+
2468+ socialClient := kiroauth.NewSocialAuthClient(h.cfg)
2469+
2470+ // Generate PKCE codes
2471+ codeVerifier, codeChallenge, errPKCE := generateKiroPKCE()
2472+ if errPKCE != nil {
2473+ log.Errorf("Failed to generate PKCE: %v", errPKCE)
2474+ SetOAuthSessionError(state, "Failed to generate PKCE")
2475+ return
2476+ }
2477+
2478+ // Build login URL
2479+ authURL := fmt.Sprintf("%s/login?idp=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&state=%s&prompt=select_account",
2480+ "https://prod.us-east-1.auth.desktop.kiro.dev",
2481+ provider,
2482+ url.QueryEscape(kiroauth.KiroRedirectURI),
2483+ codeChallenge,
2484+ state,
2485+ )
2486+
2487+ // Store auth URL for frontend.
2488+ // Using "|" as separator because URLs contain ":".
2489+ SetOAuthSessionError(state, "auth_url|"+authURL)
2490+
2491+ // Wait for callback file
2492+ waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-kiro-%s.oauth", state))
2493+ deadline := time.Now().Add(5 * time.Minute)
2494+
2495+ for {
2496+ if time.Now().After(deadline) {
2497+ log.Error("oauth flow timed out")
2498+ SetOAuthSessionError(state, "OAuth flow timed out")
2499+ return
2500+ }
2501+ if data, errRead := os.ReadFile(waitFile); errRead == nil {
2502+ var m map[string]string
2503+ _ = json.Unmarshal(data, &m)
2504+ _ = os.Remove(waitFile)
2505+ if errStr := m["error"]; errStr != "" {
2506+ log.Errorf("Authentication failed: %s", errStr)
2507+ SetOAuthSessionError(state, "Authentication failed")
2508+ return
2509+ }
2510+ if m["state"] != state {
2511+ log.Errorf("State mismatch")
2512+ SetOAuthSessionError(state, "State mismatch")
2513+ return
2514+ }
2515+ code := m["code"]
2516+ if code == "" {
2517+ log.Error("No authorization code received")
2518+ SetOAuthSessionError(state, "No authorization code received")
2519+ return
2520+ }
2521+
2522+ // Exchange code for tokens
2523+ tokenReq := &kiroauth.CreateTokenRequest{
2524+ Code: code,
2525+ CodeVerifier: codeVerifier,
2526+ RedirectURI: kiroauth.KiroRedirectURI,
2527+ }
2528+
2529+ tokenResp, errToken := socialClient.CreateToken(ctx, tokenReq)
2530+ if errToken != nil {
2531+ log.Errorf("Failed to exchange code for tokens: %v", errToken)
2532+ SetOAuthSessionError(state, "Failed to exchange code for tokens")
2533+ return
2534+ }
2535+
2536+ // Save the token
2537+ expiresIn := tokenResp.ExpiresIn
2538+ if expiresIn <= 0 {
2539+ expiresIn = 3600
2540+ }
2541+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
2542+ email := kiroauth.ExtractEmailFromJWT(tokenResp.AccessToken)
2543+
2544+ idPart := kiroauth.SanitizeEmailForFilename(email)
2545+ if idPart == "" {
2546+ idPart = fmt.Sprintf("%d", time.Now().UnixNano()%100000)
2547+ }
2548+
2549+ now := time.Now()
2550+ fileName := fmt.Sprintf("kiro-%s-%s.json", strings.ToLower(provider), idPart)
2551+
2552+ record := &coreauth.Auth{
2553+ ID: fileName,
2554+ Provider: "kiro",
2555+ FileName: fileName,
2556+ Metadata: map[string]any{
2557+ "type": "kiro",
2558+ "access_token": tokenResp.AccessToken,
2559+ "refresh_token": tokenResp.RefreshToken,
2560+ "profile_arn": tokenResp.ProfileArn,
2561+ "expires_at": expiresAt.Format(time.RFC3339),
2562+ "auth_method": "social",
2563+ "provider": provider,
2564+ "email": email,
2565+ "last_refresh": now.Format(time.RFC3339),
2566+ },
2567+ }
2568+
2569+ savedPath, errSave := h.saveTokenRecord(ctx, record)
2570+ if errSave != nil {
2571+ log.Errorf("Failed to save authentication tokens: %v", errSave)
2572+ SetOAuthSessionError(state, "Failed to save authentication tokens")
2573+ return
2574+ }
2575+
2576+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
2577+ if email != "" {
2578+ fmt.Printf("Authenticated as: %s\n", email)
2579+ }
2580+ CompleteOAuthSession(state)
2581+ return
2582+ }
2583+ time.Sleep(500 * time.Millisecond)
2584+ }
2585+ }()
2586+
2587+ c.JSON(http.StatusOK, gin.H{"status": "ok", "state": state, "method": "social"})
2588+
2589+ default:
2590+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid method, use 'aws', 'google', or 'github'"})
2591+ }
2592+}
2593+
2594+// generateKiroPKCE generates PKCE code verifier and challenge for Kiro OAuth.
2595+func generateKiroPKCE() (verifier, challenge string, err error) {
2596+ b := make([]byte, 32)
2597+ if _, errRead := io.ReadFull(rand.Reader, b); errRead != nil {
2598+ return "", "", fmt.Errorf("failed to generate random bytes: %w", errRead)
2599+ }
2600+ verifier = base64.RawURLEncoding.EncodeToString(b)
2601+
2602+ h := sha256.Sum256([]byte(verifier))
2603+ challenge = base64.RawURLEncoding.EncodeToString(h[:])
2604+
2605+ return verifier, challenge, nil
2606+}
internal/api/handlers/management/config_basic.go+2−2
@@ -19,8 +19,8 @@ import ( · 18 unmodified lines
1919 )
2020
2121 const (
22− latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"
23− latestReleaseUserAgent = "CLIProxyAPI"
22+ latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest"
23+ latestReleaseUserAgent = "CLIProxyAPIPlus"
2424 )
2525
2626 func (h *Handler) GetConfig(c *gin.Context) {
internal/api/handlers/management/oauth_sessions.go+8−1
@@ -158,7 +158,12 @@ func (s *oauthSessionStore) IsPending(state, provider string) bool { · 157 unmodified lines
158158 return false
159159 }
160160 if session.Status != "" {
161− return false
161+ if !strings.EqualFold(session.Provider, "kiro") {
162+ return false
163+ }
164+ if !strings.HasPrefix(session.Status, "device_code|") && !strings.HasPrefix(session.Status, "auth_url|") {
165+ return false
166+ }
162167 }
163168 if provider == "" {
164169 return true
@@ -231,6 +236,8 @@ func NormalizeOAuthProvider(provider string) (string, error) { · 66 unmodified lines
231236 return "antigravity", nil
232237 case "qwen":
233238 return "qwen", nil
239+ case "kiro":
240+ return "kiro", nil
234241 default:
235242 return "", errUnsupportedOAuthFlow
236243 }
internal/api/modules/amp/proxy.go+34−3
@@ -3,8 +3,11 @@ package amp · 2 unmodified lines
33 import (
44 "bytes"
55 "compress/gzip"
6+ "context"
7+ "errors"
68 "fmt"
79 "io"
10+ "net"
811 "net/http"
912 "net/http/httputil"
1013 "net/url"
@@ -102,7 +105,15 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi · 91 unmodified lines
102105 // Modify incoming responses to handle gzip without Content-Encoding
103106 // This addresses the same issue as inline handler gzip handling, but at the proxy level
104107 proxy.ModifyResponse = func(resp *http.Response) error {
105− // Only process successful responses
108+ // Log upstream error responses for diagnostics (502, 503, etc.)
109+ // These are NOT proxy connection errors - the upstream responded with an error status
110+ if resp.StatusCode >= 500 {
111+ log.Errorf("amp upstream responded with error [%d] for %s %s", resp.StatusCode, resp.Request.Method, resp.Request.URL.Path)
112+ } else if resp.StatusCode >= 400 {
113+ log.Warnf("amp upstream responded with client error [%d] for %s %s", resp.StatusCode, resp.Request.Method, resp.Request.URL.Path)
114+ }
115+
116+ // Only process successful responses for gzip decompression
106117 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
107118 return nil
108119 }
@@ -186,9 +197,29 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi · 77 unmodified lines
186197 return nil
187198 }
188199
189− // Error handler for proxy failures
200+ // Error handler for proxy failures with detailed error classification for diagnostics
190201 proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
191− log.Errorf("amp upstream proxy error for %s %s: %v", req.Method, req.URL.Path, err)
202+ // Classify the error type for better diagnostics
203+ var errType string
204+ if errors.Is(err, context.DeadlineExceeded) {
205+ errType = "timeout"
206+ } else if errors.Is(err, context.Canceled) {
207+ errType = "canceled"
208+ } else if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
209+ errType = "dial_timeout"
210+ } else if _, ok := err.(net.Error); ok {
211+ errType = "network_error"
212+ } else {
213+ errType = "connection_error"
214+ }
215+
216+ // Don't log as error for context canceled - it's usually client closing connection
217+ if errors.Is(err, context.Canceled) {
218+ log.Debugf("amp upstream proxy [%s]: client canceled request for %s %s", errType, req.Method, req.URL.Path)
219+ } else {
220+ log.Errorf("amp upstream proxy error [%s] for %s %s: %v", errType, req.Method, req.URL.Path, err)
221+ }
222+
192223 rw.Header().Set("Content-Type", "application/json")
193224 rw.WriteHeader(http.StatusBadGateway)
194225 _, _ = rw.Write([]byte(`{"error":"amp_upstream_proxy_error","message":"Failed to reach Amp upstream"}`))
internal/api/modules/amp/response_rewriter.go+58−2
@@ -29,15 +29,71 @@ func NewResponseRewriter(w gin.ResponseWriter, originalModel string) *ResponseRe · 28 unmodified lines
2929 }
3030 }
3131
32+const maxBufferedResponseBytes = 2 * 1024 * 1024 // 2MB safety cap
33+
34+func looksLikeSSEChunk(data []byte) bool {
35+ // Fallback detection: some upstreams may omit/lie about Content-Type, causing SSE to be buffered.
36+ // Heuristics are intentionally simple and cheap.
37+ return bytes.Contains(data, []byte("data:")) ||
38+ bytes.Contains(data, []byte("event:")) ||
39+ bytes.Contains(data, []byte("message_start")) ||
40+ bytes.Contains(data, []byte("message_delta")) ||
41+ bytes.Contains(data, []byte("content_block_start")) ||
42+ bytes.Contains(data, []byte("content_block_delta")) ||
43+ bytes.Contains(data, []byte("content_block_stop")) ||
44+ bytes.Contains(data, []byte("\n\n"))
45+}
46+
47+func (rw *ResponseRewriter) enableStreaming(reason string) error {
48+ if rw.isStreaming {
49+ return nil
50+ }
51+ rw.isStreaming = true
52+
53+ // Flush any previously buffered data to avoid reordering or data loss.
54+ if rw.body != nil && rw.body.Len() > 0 {
55+ buf := rw.body.Bytes()
56+ // Copy before Reset() to keep bytes stable.
57+ toFlush := make([]byte, len(buf))
58+ copy(toFlush, buf)
59+ rw.body.Reset()
60+
61+ if _, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(toFlush)); err != nil {
62+ return err
63+ }
64+ if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
65+ flusher.Flush()
66+ }
67+ }
68+
69+ log.Debugf("amp response rewriter: switched to streaming (%s)", reason)
70+ return nil
71+}
72+
3273 // Write intercepts response writes and buffers them for model name replacement
3374 func (rw *ResponseRewriter) Write(data []byte) (int, error) {
34− // Detect streaming on first write
35− if rw.body.Len() == 0 && !rw.isStreaming {
75+ // Detect streaming on first write (header-based)
76+ if !rw.isStreaming && rw.body.Len() == 0 {
3677 contentType := rw.Header().Get("Content-Type")
3778 rw.isStreaming = strings.Contains(contentType, "text/event-stream") ||
3879 strings.Contains(contentType, "stream")
3980 }
4081
82+ if !rw.isStreaming {
83+ // Content-based fallback: detect SSE-like chunks even if Content-Type is missing/wrong.
84+ if looksLikeSSEChunk(data) {
85+ if err := rw.enableStreaming("sse heuristic"); err != nil {
86+ return 0, err
87+ }
88+ } else if rw.body.Len()+len(data) > maxBufferedResponseBytes {
89+ // Safety cap: avoid unbounded buffering on large responses.
90+ log.Warnf("amp response rewriter: buffer exceeded %d bytes, switching to streaming", maxBufferedResponseBytes)
91+ if err := rw.enableStreaming("buffer limit"); err != nil {
92+ return 0, err
93+ }
94+ }
95+ }
96+
4197 if rw.isStreaming {
4298 n, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(data))
4399 if err == nil {
internal/api/server.go+21−0
@@ -347,6 +347,12 @@ func (s *Server) setupRoutes() { · 346 unmodified lines
347347 },
348348 })
349349 })
350+
351+ // Event logging endpoint - handles Claude Code telemetry requests
352+ // Returns 200 OK to prevent 404 errors in logs
353+ s.engine.POST("/api/event_logging/batch", func(c *gin.Context) {
354+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
355+ })
350356 s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
351357
352358 // OAuth callback endpoints (reuse main server port)
@@ -422,6 +428,20 @@ func (s *Server) setupRoutes() { · 69 unmodified lines
422428 c.String(http.StatusOK, oauthCallbackSuccessHTML)
423429 })
424430
431+ s.engine.GET("/kiro/callback", func(c *gin.Context) {
432+ code := c.Query("code")
433+ state := c.Query("state")
434+ errStr := c.Query("error")
435+ if errStr == "" {
436+ errStr = c.Query("error_description")
437+ }
438+ if state != "" {
439+ _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "kiro", state, code, errStr)
440+ }
441+ c.Header("Content-Type", "text/html; charset=utf-8")
442+ c.String(http.StatusOK, oauthCallbackSuccessHTML)
443+ })
444+
425445 // Management routes are registered lazily by registerManagementRoutes when a secret is configured.
426446 }
427447
@@ -597,6 +617,7 @@ func (s *Server) registerManagementRoutes() { · 169 unmodified lines
597617 mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
598618 mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
599619 mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
620+ mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
600621 mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
601622 mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
602623 }
internal/auth/claude/oauth_server.go+11−0
@@ -242,6 +242,11 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) { · 241 unmodified lines
242242 platformURL = "https://console.anthropic.com/"
243243 }
244244
245+ // Validate platformURL to prevent XSS - only allow http/https URLs
246+ if !isValidURL(platformURL) {
247+ platformURL = "https://console.anthropic.com/"
248+ }
249+
245250 // Generate success page HTML with dynamic content
246251 successHTML := s.generateSuccessHTML(setupRequired, platformURL)
247252
@@ -251,6 +256,12 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) { · 3 unmodified lines
251256 }
252257 }
253258
259+// isValidURL checks if the URL is a valid http/https URL to prevent XSS
260+func isValidURL(urlStr string) bool {
261+ urlStr = strings.TrimSpace(urlStr)
262+ return strings.HasPrefix(urlStr, "https://") || strings.HasPrefix(urlStr, "http://")
263+}
264+
254265 // generateSuccessHTML creates the HTML content for the success page.
255266 // It customizes the page based on whether additional setup is required
256267 // and includes a link to the platform.
internal/auth/codex/oauth_server.go+11−0
@@ -239,6 +239,11 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) { · 238 unmodified lines
239239 platformURL = "https://platform.openai.com"
240240 }
241241
242+ // Validate platformURL to prevent XSS - only allow http/https URLs
243+ if !isValidURL(platformURL) {
244+ platformURL = "https://platform.openai.com"
245+ }
246+
242247 // Generate success page HTML with dynamic content
243248 successHTML := s.generateSuccessHTML(setupRequired, platformURL)
244249
@@ -248,6 +253,12 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) { · 3 unmodified lines
248253 }
249254 }
250255
256+// isValidURL checks if the URL is a valid http/https URL to prevent XSS
257+func isValidURL(urlStr string) bool {
258+ urlStr = strings.TrimSpace(urlStr)
259+ return strings.HasPrefix(urlStr, "https://") || strings.HasPrefix(urlStr, "http://")
260+}
261+
251262 // generateSuccessHTML creates the HTML content for the success page.
252263 // It customizes the page based on whether additional setup is required
253264 // and includes a link to the platform.
internal/auth/copilot/copilot_auth.go+225−0
@@ -0,0 +1,225 @@
1+// Package copilot provides authentication and token management for GitHub Copilot API.
2+// It handles the OAuth2 device flow for secure authentication with the Copilot API.
3+package copilot
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "time"
12+
13+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
15+ log "github.com/sirupsen/logrus"
16+)
17+
18+const (
19+ // copilotAPITokenURL is the endpoint for getting Copilot API tokens from GitHub token.
20+ copilotAPITokenURL = "https://api.github.com/copilot_internal/v2/token"
21+ // copilotAPIEndpoint is the base URL for making API requests.
22+ copilotAPIEndpoint = "https://api.githubcopilot.com"
23+
24+ // Common HTTP header values for Copilot API requests.
25+ copilotUserAgent = "GithubCopilot/1.0"
26+ copilotEditorVersion = "vscode/1.100.0"
27+ copilotPluginVersion = "copilot/1.300.0"
28+ copilotIntegrationID = "vscode-chat"
29+ copilotOpenAIIntent = "conversation-panel"
30+)
31+
32+// CopilotAPIToken represents the Copilot API token response.
33+type CopilotAPIToken struct {
34+ // Token is the JWT token for authenticating with the Copilot API.
35+ Token string `json:"token"`
36+ // ExpiresAt is the Unix timestamp when the token expires.
37+ ExpiresAt int64 `json:"expires_at"`
38+ // Endpoints contains the available API endpoints.
39+ Endpoints struct {
40+ API string `json:"api"`
41+ Proxy string `json:"proxy"`
42+ OriginTracker string `json:"origin-tracker"`
43+ Telemetry string `json:"telemetry"`
44+ } `json:"endpoints,omitempty"`
45+ // ErrorDetails contains error information if the request failed.
46+ ErrorDetails *struct {
47+ URL string `json:"url"`
48+ Message string `json:"message"`
49+ DocumentationURL string `json:"documentation_url"`
50+ } `json:"error_details,omitempty"`
51+}
52+
53+// CopilotAuth handles GitHub Copilot authentication flow.
54+// It provides methods for device flow authentication and token management.
55+type CopilotAuth struct {
56+ httpClient *http.Client
57+ deviceClient *DeviceFlowClient
58+ cfg *config.Config
59+}
60+
61+// NewCopilotAuth creates a new CopilotAuth service instance.
62+// It initializes an HTTP client with proxy settings from the provided configuration.
63+func NewCopilotAuth(cfg *config.Config) *CopilotAuth {
64+ return &CopilotAuth{
65+ httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}),
66+ deviceClient: NewDeviceFlowClient(cfg),
67+ cfg: cfg,
68+ }
69+}
70+
71+// StartDeviceFlow initiates the device flow authentication.
72+// Returns the device code response containing the user code and verification URI.
73+func (c *CopilotAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
74+ return c.deviceClient.RequestDeviceCode(ctx)
75+}
76+
77+// WaitForAuthorization polls for user authorization and returns the auth bundle.
78+func (c *CopilotAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotAuthBundle, error) {
79+ tokenData, err := c.deviceClient.PollForToken(ctx, deviceCode)
80+ if err != nil {
81+ return nil, err
82+ }
83+
84+ // Fetch the GitHub username
85+ username, err := c.deviceClient.FetchUserInfo(ctx, tokenData.AccessToken)
86+ if err != nil {
87+ log.Warnf("copilot: failed to fetch user info: %v", err)
88+ username = "unknown"
89+ }
90+
91+ return &CopilotAuthBundle{
92+ TokenData: tokenData,
93+ Username: username,
94+ }, nil
95+}
96+
97+// GetCopilotAPIToken exchanges a GitHub access token for a Copilot API token.
98+// This token is used to make authenticated requests to the Copilot API.
99+func (c *CopilotAuth) GetCopilotAPIToken(ctx context.Context, githubAccessToken string) (*CopilotAPIToken, error) {
100+ if githubAccessToken == "" {
101+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("github access token is empty"))
102+ }
103+
104+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotAPITokenURL, nil)
105+ if err != nil {
106+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
107+ }
108+
109+ req.Header.Set("Authorization", "token "+githubAccessToken)
110+ req.Header.Set("Accept", "application/json")
111+ req.Header.Set("User-Agent", copilotUserAgent)
112+ req.Header.Set("Editor-Version", copilotEditorVersion)
113+ req.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
114+
115+ resp, err := c.httpClient.Do(req)
116+ if err != nil {
117+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
118+ }
119+ defer func() {
120+ if errClose := resp.Body.Close(); errClose != nil {
121+ log.Errorf("copilot api token: close body error: %v", errClose)
122+ }
123+ }()
124+
125+ bodyBytes, err := io.ReadAll(resp.Body)
126+ if err != nil {
127+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
128+ }
129+
130+ if !isHTTPSuccess(resp.StatusCode) {
131+ return nil, NewAuthenticationError(ErrTokenExchangeFailed,
132+ fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
133+ }
134+
135+ var apiToken CopilotAPIToken
136+ if err = json.Unmarshal(bodyBytes, &apiToken); err != nil {
137+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
138+ }
139+
140+ if apiToken.Token == "" {
141+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty copilot api token"))
142+ }
143+
144+ return &apiToken, nil
145+}
146+
147+// ValidateToken checks if a GitHub access token is valid by attempting to fetch user info.
148+func (c *CopilotAuth) ValidateToken(ctx context.Context, accessToken string) (bool, string, error) {
149+ if accessToken == "" {
150+ return false, "", nil
151+ }
152+
153+ username, err := c.deviceClient.FetchUserInfo(ctx, accessToken)
154+ if err != nil {
155+ return false, "", err
156+ }
157+
158+ return true, username, nil
159+}
160+
161+// CreateTokenStorage creates a new CopilotTokenStorage from auth bundle.
162+func (c *CopilotAuth) CreateTokenStorage(bundle *CopilotAuthBundle) *CopilotTokenStorage {
163+ return &CopilotTokenStorage{
164+ AccessToken: bundle.TokenData.AccessToken,
165+ TokenType: bundle.TokenData.TokenType,
166+ Scope: bundle.TokenData.Scope,
167+ Username: bundle.Username,
168+ Type: "github-copilot",
169+ }
170+}
171+
172+// LoadAndValidateToken loads a token from storage and validates it.
173+// Returns the storage if valid, or an error if the token is invalid or expired.
174+func (c *CopilotAuth) LoadAndValidateToken(ctx context.Context, storage *CopilotTokenStorage) (bool, error) {
175+ if storage == nil || storage.AccessToken == "" {
176+ return false, fmt.Errorf("no token available")
177+ }
178+
179+ // Check if we can still use the GitHub token to get a Copilot API token
180+ apiToken, err := c.GetCopilotAPIToken(ctx, storage.AccessToken)
181+ if err != nil {
182+ return false, err
183+ }
184+
185+ // Check if the API token is expired
186+ if apiToken.ExpiresAt > 0 && time.Now().Unix() >= apiToken.ExpiresAt {
187+ return false, fmt.Errorf("copilot api token expired")
188+ }
189+
190+ return true, nil
191+}
192+
193+// GetAPIEndpoint returns the Copilot API endpoint URL.
194+func (c *CopilotAuth) GetAPIEndpoint() string {
195+ return copilotAPIEndpoint
196+}
197+
198+// MakeAuthenticatedRequest creates an authenticated HTTP request to the Copilot API.
199+func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url string, body io.Reader, apiToken *CopilotAPIToken) (*http.Request, error) {
200+ req, err := http.NewRequestWithContext(ctx, method, url, body)
201+ if err != nil {
202+ return nil, fmt.Errorf("failed to create request: %w", err)
203+ }
204+
205+ req.Header.Set("Authorization", "Bearer "+apiToken.Token)
206+ req.Header.Set("Content-Type", "application/json")
207+ req.Header.Set("Accept", "application/json")
208+ req.Header.Set("User-Agent", copilotUserAgent)
209+ req.Header.Set("Editor-Version", copilotEditorVersion)
210+ req.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
211+ req.Header.Set("Openai-Intent", copilotOpenAIIntent)
212+ req.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
213+
214+ return req, nil
215+}
216+
217+// buildChatCompletionURL builds the URL for chat completions API.
218+func buildChatCompletionURL() string {
219+ return copilotAPIEndpoint + "/chat/completions"
220+}
221+
222+// isHTTPSuccess checks if the status code indicates success (2xx).
223+func isHTTPSuccess(statusCode int) bool {
224+ return statusCode >= 200 && statusCode < 300
225+}
internal/auth/copilot/errors.go+187−0
@@ -0,0 +1,187 @@
1+package copilot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net/http"
7+)
8+
9+// OAuthError represents an OAuth-specific error.
10+type OAuthError struct {
11+ // Code is the OAuth error code.
12+ Code string `json:"error"`
13+ // Description is a human-readable description of the error.
14+ Description string `json:"error_description,omitempty"`
15+ // URI is a URI identifying a human-readable web page with information about the error.
16+ URI string `json:"error_uri,omitempty"`
17+ // StatusCode is the HTTP status code associated with the error.
18+ StatusCode int `json:"-"`
19+}
20+
21+// Error returns a string representation of the OAuth error.
22+func (e *OAuthError) Error() string {
23+ if e.Description != "" {
24+ return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
25+ }
26+ return fmt.Sprintf("OAuth error: %s", e.Code)
27+}
28+
29+// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
30+func NewOAuthError(code, description string, statusCode int) *OAuthError {
31+ return &OAuthError{
32+ Code: code,
33+ Description: description,
34+ StatusCode: statusCode,
35+ }
36+}
37+
38+// AuthenticationError represents authentication-related errors.
39+type AuthenticationError struct {
40+ // Type is the type of authentication error.
41+ Type string `json:"type"`
42+ // Message is a human-readable message describing the error.
43+ Message string `json:"message"`
44+ // Code is the HTTP status code associated with the error.
45+ Code int `json:"code"`
46+ // Cause is the underlying error that caused this authentication error.
47+ Cause error `json:"-"`
48+}
49+
50+// Error returns a string representation of the authentication error.
51+func (e *AuthenticationError) Error() string {
52+ if e.Cause != nil {
53+ return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
54+ }
55+ return fmt.Sprintf("%s: %s", e.Type, e.Message)
56+}
57+
58+// Unwrap returns the underlying cause of the error.
59+func (e *AuthenticationError) Unwrap() error {
60+ return e.Cause
61+}
62+
63+// Common authentication error types for GitHub Copilot device flow.
64+var (
65+ // ErrDeviceCodeFailed represents an error when requesting the device code fails.
66+ ErrDeviceCodeFailed = &AuthenticationError{
67+ Type: "device_code_failed",
68+ Message: "Failed to request device code from GitHub",
69+ Code: http.StatusBadRequest,
70+ }
71+
72+ // ErrDeviceCodeExpired represents an error when the device code has expired.
73+ ErrDeviceCodeExpired = &AuthenticationError{
74+ Type: "device_code_expired",
75+ Message: "Device code has expired. Please try again.",
76+ Code: http.StatusGone,
77+ }
78+
79+ // ErrAuthorizationPending represents a pending authorization state (not an error, used for polling).
80+ ErrAuthorizationPending = &AuthenticationError{
81+ Type: "authorization_pending",
82+ Message: "Authorization is pending. Waiting for user to authorize.",
83+ Code: http.StatusAccepted,
84+ }
85+
86+ // ErrSlowDown represents a request to slow down polling.
87+ ErrSlowDown = &AuthenticationError{
88+ Type: "slow_down",
89+ Message: "Polling too frequently. Slowing down.",
90+ Code: http.StatusTooManyRequests,
91+ }
92+
93+ // ErrAccessDenied represents an error when the user denies authorization.
94+ ErrAccessDenied = &AuthenticationError{
95+ Type: "access_denied",
96+ Message: "User denied authorization",
97+ Code: http.StatusForbidden,
98+ }
99+
100+ // ErrTokenExchangeFailed represents an error when token exchange fails.
101+ ErrTokenExchangeFailed = &AuthenticationError{
102+ Type: "token_exchange_failed",
103+ Message: "Failed to exchange device code for access token",
104+ Code: http.StatusBadRequest,
105+ }
106+
107+ // ErrPollingTimeout represents an error when polling times out.
108+ ErrPollingTimeout = &AuthenticationError{
109+ Type: "polling_timeout",
110+ Message: "Timeout waiting for user authorization",
111+ Code: http.StatusRequestTimeout,
112+ }
113+
114+ // ErrUserInfoFailed represents an error when fetching user info fails.
115+ ErrUserInfoFailed = &AuthenticationError{
116+ Type: "user_info_failed",
117+ Message: "Failed to fetch GitHub user information",
118+ Code: http.StatusBadRequest,
119+ }
120+)
121+
122+// NewAuthenticationError creates a new authentication error with a cause based on a base error.
123+func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
124+ return &AuthenticationError{
125+ Type: baseErr.Type,
126+ Message: baseErr.Message,
127+ Code: baseErr.Code,
128+ Cause: cause,
129+ }
130+}
131+
132+// IsAuthenticationError checks if an error is an authentication error.
133+func IsAuthenticationError(err error) bool {
134+ var authenticationError *AuthenticationError
135+ ok := errors.As(err, &authenticationError)
136+ return ok
137+}
138+
139+// IsOAuthError checks if an error is an OAuth error.
140+func IsOAuthError(err error) bool {
141+ var oAuthError *OAuthError
142+ ok := errors.As(err, &oAuthError)
143+ return ok
144+}
145+
146+// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
147+func GetUserFriendlyMessage(err error) string {
148+ var authErr *AuthenticationError
149+ if errors.As(err, &authErr) {
150+ switch authErr.Type {
151+ case "device_code_failed":
152+ return "Failed to start GitHub authentication. Please check your network connection and try again."
153+ case "device_code_expired":
154+ return "The authentication code has expired. Please try again."
155+ case "authorization_pending":
156+ return "Waiting for you to authorize the application on GitHub."
157+ case "slow_down":
158+ return "Please wait a moment before trying again."
159+ case "access_denied":
160+ return "Authentication was cancelled or denied."
161+ case "token_exchange_failed":
162+ return "Failed to complete authentication. Please try again."
163+ case "polling_timeout":
164+ return "Authentication timed out. Please try again."
165+ case "user_info_failed":
166+ return "Failed to get your GitHub account information. Please try again."
167+ default:
168+ return "Authentication failed. Please try again."
169+ }
170+ }
171+
172+ var oauthErr *OAuthError
173+ if errors.As(err, &oauthErr) {
174+ switch oauthErr.Code {
175+ case "access_denied":
176+ return "Authentication was cancelled or denied."
177+ case "invalid_request":
178+ return "Invalid authentication request. Please try again."
179+ case "server_error":
180+ return "GitHub server error. Please try again later."
181+ default:
182+ return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
183+ }
184+ }
185+
186+ return "An unexpected error occurred. Please try again."
187+}
internal/auth/copilot/oauth.go+255−0
@@ -0,0 +1,255 @@
1+package copilot
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "time"
13+
14+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
16+ log "github.com/sirupsen/logrus"
17+)
18+
19+const (
20+ // copilotClientID is GitHub's Copilot CLI OAuth client ID.
21+ copilotClientID = "Iv1.b507a08c87ecfe98"
22+ // copilotDeviceCodeURL is the endpoint for requesting device codes.
23+ copilotDeviceCodeURL = "https://github.com/login/device/code"
24+ // copilotTokenURL is the endpoint for exchanging device codes for tokens.
25+ copilotTokenURL = "https://github.com/login/oauth/access_token"
26+ // copilotUserInfoURL is the endpoint for fetching GitHub user information.
27+ copilotUserInfoURL = "https://api.github.com/user"
28+ // defaultPollInterval is the default interval for polling token endpoint.
29+ defaultPollInterval = 5 * time.Second
30+ // maxPollDuration is the maximum time to wait for user authorization.
31+ maxPollDuration = 15 * time.Minute
32+)
33+
34+// DeviceFlowClient handles the OAuth2 device flow for GitHub Copilot.
35+type DeviceFlowClient struct {
36+ httpClient *http.Client
37+ cfg *config.Config
38+}
39+
40+// NewDeviceFlowClient creates a new device flow client.
41+func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient {
42+ client := &http.Client{Timeout: 30 * time.Second}
43+ if cfg != nil {
44+ client = util.SetProxy(&cfg.SDKConfig, client)
45+ }
46+ return &DeviceFlowClient{
47+ httpClient: client,
48+ cfg: cfg,
49+ }
50+}
51+
52+// RequestDeviceCode initiates the device flow by requesting a device code from GitHub.
53+func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) {
54+ data := url.Values{}
55+ data.Set("client_id", copilotClientID)
56+ data.Set("scope", "user:email")
57+
58+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotDeviceCodeURL, strings.NewReader(data.Encode()))
59+ if err != nil {
60+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
61+ }
62+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
63+ req.Header.Set("Accept", "application/json")
64+
65+ resp, err := c.httpClient.Do(req)
66+ if err != nil {
67+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
68+ }
69+ defer func() {
70+ if errClose := resp.Body.Close(); errClose != nil {
71+ log.Errorf("copilot device code: close body error: %v", errClose)
72+ }
73+ }()
74+
75+ if !isHTTPSuccess(resp.StatusCode) {
76+ bodyBytes, _ := io.ReadAll(resp.Body)
77+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
78+ }
79+
80+ var deviceCode DeviceCodeResponse
81+ if err = json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil {
82+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
83+ }
84+
85+ return &deviceCode, nil
86+}
87+
88+// PollForToken polls the token endpoint until the user authorizes or the device code expires.
89+func (c *DeviceFlowClient) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotTokenData, error) {
90+ if deviceCode == nil {
91+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("device code is nil"))
92+ }
93+
94+ interval := time.Duration(deviceCode.Interval) * time.Second
95+ if interval < defaultPollInterval {
96+ interval = defaultPollInterval
97+ }
98+
99+ deadline := time.Now().Add(maxPollDuration)
100+ if deviceCode.ExpiresIn > 0 {
101+ codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
102+ if codeDeadline.Before(deadline) {
103+ deadline = codeDeadline
104+ }
105+ }
106+
107+ ticker := time.NewTicker(interval)
108+ defer ticker.Stop()
109+
110+ for {
111+ select {
112+ case <-ctx.Done():
113+ return nil, NewAuthenticationError(ErrPollingTimeout, ctx.Err())
114+ case <-ticker.C:
115+ if time.Now().After(deadline) {
116+ return nil, ErrPollingTimeout
117+ }
118+
119+ token, err := c.exchangeDeviceCode(ctx, deviceCode.DeviceCode)
120+ if err != nil {
121+ var authErr *AuthenticationError
122+ if errors.As(err, &authErr) {
123+ switch authErr.Type {
124+ case ErrAuthorizationPending.Type:
125+ // Continue polling
126+ continue
127+ case ErrSlowDown.Type:
128+ // Increase interval and continue
129+ interval += 5 * time.Second
130+ ticker.Reset(interval)
131+ continue
132+ case ErrDeviceCodeExpired.Type:
133+ return nil, err
134+ case ErrAccessDenied.Type:
135+ return nil, err
136+ }
137+ }
138+ return nil, err
139+ }
140+ return token, nil
141+ }
142+ }
143+}
144+
145+// exchangeDeviceCode attempts to exchange the device code for an access token.
146+func (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode string) (*CopilotTokenData, error) {
147+ data := url.Values{}
148+ data.Set("client_id", copilotClientID)
149+ data.Set("device_code", deviceCode)
150+ data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
151+
152+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotTokenURL, strings.NewReader(data.Encode()))
153+ if err != nil {
154+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
155+ }
156+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
157+ req.Header.Set("Accept", "application/json")
158+
159+ resp, err := c.httpClient.Do(req)
160+ if err != nil {
161+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
162+ }
163+ defer func() {
164+ if errClose := resp.Body.Close(); errClose != nil {
165+ log.Errorf("copilot token exchange: close body error: %v", errClose)
166+ }
167+ }()
168+
169+ bodyBytes, err := io.ReadAll(resp.Body)
170+ if err != nil {
171+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
172+ }
173+
174+ // GitHub returns 200 for both success and error cases in device flow
175+ // Check for OAuth error response first
176+ var oauthResp struct {
177+ Error string `json:"error"`
178+ ErrorDescription string `json:"error_description"`
179+ AccessToken string `json:"access_token"`
180+ TokenType string `json:"token_type"`
181+ Scope string `json:"scope"`
182+ }
183+
184+ if err = json.Unmarshal(bodyBytes, &oauthResp); err != nil {
185+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
186+ }
187+
188+ if oauthResp.Error != "" {
189+ switch oauthResp.Error {
190+ case "authorization_pending":
191+ return nil, ErrAuthorizationPending
192+ case "slow_down":
193+ return nil, ErrSlowDown
194+ case "expired_token":
195+ return nil, ErrDeviceCodeExpired
196+ case "access_denied":
197+ return nil, ErrAccessDenied
198+ default:
199+ return nil, NewOAuthError(oauthResp.Error, oauthResp.ErrorDescription, resp.StatusCode)
200+ }
201+ }
202+
203+ if oauthResp.AccessToken == "" {
204+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty access token"))
205+ }
206+
207+ return &CopilotTokenData{
208+ AccessToken: oauthResp.AccessToken,
209+ TokenType: oauthResp.TokenType,
210+ Scope: oauthResp.Scope,
211+ }, nil
212+}
213+
214+// FetchUserInfo retrieves the GitHub username for the authenticated user.
215+func (c *DeviceFlowClient) FetchUserInfo(ctx context.Context, accessToken string) (string, error) {
216+ if accessToken == "" {
217+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("access token is empty"))
218+ }
219+
220+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotUserInfoURL, nil)
221+ if err != nil {
222+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
223+ }
224+ req.Header.Set("Authorization", "Bearer "+accessToken)
225+ req.Header.Set("Accept", "application/json")
226+ req.Header.Set("User-Agent", "CLIProxyAPI")
227+
228+ resp, err := c.httpClient.Do(req)
229+ if err != nil {
230+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
231+ }
232+ defer func() {
233+ if errClose := resp.Body.Close(); errClose != nil {
234+ log.Errorf("copilot user info: close body error: %v", errClose)
235+ }
236+ }()
237+
238+ if !isHTTPSuccess(resp.StatusCode) {
239+ bodyBytes, _ := io.ReadAll(resp.Body)
240+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
241+ }
242+
243+ var userInfo struct {
244+ Login string `json:"login"`
245+ }
246+ if err = json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
247+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
248+ }
249+
250+ if userInfo.Login == "" {
251+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("empty username"))
252+ }
253+
254+ return userInfo.Login, nil
255+}
internal/auth/copilot/token.go+93−0
@@ -0,0 +1,93 @@
1+// Package copilot provides authentication and token management functionality
2+// for GitHub Copilot AI services. It handles OAuth2 device flow token storage,
3+// serialization, and retrieval for maintaining authenticated sessions with the Copilot API.
4+package copilot
5+
6+import (
7+ "encoding/json"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+
12+ "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
13+)
14+
15+// CopilotTokenStorage stores OAuth2 token information for GitHub Copilot API authentication.
16+// It maintains compatibility with the existing auth system while adding Copilot-specific fields
17+// for managing access tokens and user account information.
18+type CopilotTokenStorage struct {
19+ // AccessToken is the OAuth2 access token used for authenticating API requests.
20+ AccessToken string `json:"access_token"`
21+ // TokenType is the type of token, typically "bearer".
22+ TokenType string `json:"token_type"`
23+ // Scope is the OAuth2 scope granted to the token.
24+ Scope string `json:"scope"`
25+ // ExpiresAt is the timestamp when the access token expires (if provided).
26+ ExpiresAt string `json:"expires_at,omitempty"`
27+ // Username is the GitHub username associated with this token.
28+ Username string `json:"username"`
29+ // Type indicates the authentication provider type, always "github-copilot" for this storage.
30+ Type string `json:"type"`
31+}
32+
33+// CopilotTokenData holds the raw OAuth token response from GitHub.
34+type CopilotTokenData struct {
35+ // AccessToken is the OAuth2 access token.
36+ AccessToken string `json:"access_token"`
37+ // TokenType is the type of token, typically "bearer".
38+ TokenType string `json:"token_type"`
39+ // Scope is the OAuth2 scope granted to the token.
40+ Scope string `json:"scope"`
41+}
42+
43+// CopilotAuthBundle bundles authentication data for storage.
44+type CopilotAuthBundle struct {
45+ // TokenData contains the OAuth token information.
46+ TokenData *CopilotTokenData
47+ // Username is the GitHub username.
48+ Username string
49+}
50+
51+// DeviceCodeResponse represents GitHub's device code response.
52+type DeviceCodeResponse struct {
53+ // DeviceCode is the device verification code.
54+ DeviceCode string `json:"device_code"`
55+ // UserCode is the code the user must enter at the verification URI.
56+ UserCode string `json:"user_code"`
57+ // VerificationURI is the URL where the user should enter the code.
58+ VerificationURI string `json:"verification_uri"`
59+ // ExpiresIn is the number of seconds until the device code expires.
60+ ExpiresIn int `json:"expires_in"`
61+ // Interval is the minimum number of seconds to wait between polling requests.
62+ Interval int `json:"interval"`
63+}
64+
65+// SaveTokenToFile serializes the Copilot token storage to a JSON file.
66+// This method creates the necessary directory structure and writes the token
67+// data in JSON format to the specified file path for persistent storage.
68+//
69+// Parameters:
70+// - authFilePath: The full path where the token file should be saved
71+//
72+// Returns:
73+// - error: An error if the operation fails, nil otherwise
74+func (ts *CopilotTokenStorage) SaveTokenToFile(authFilePath string) error {
75+ misc.LogSavingCredentials(authFilePath)
76+ ts.Type = "github-copilot"
77+ if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
78+ return fmt.Errorf("failed to create directory: %v", err)
79+ }
80+
81+ f, err := os.Create(authFilePath)
82+ if err != nil {
83+ return fmt.Errorf("failed to create token file: %w", err)
84+ }
85+ defer func() {
86+ _ = f.Close()
87+ }()
88+
89+ if err = json.NewEncoder(f).Encode(ts); err != nil {
90+ return fmt.Errorf("failed to write token to file: %w", err)
91+ }
92+ return nil
93+}
internal/auth/iflow/iflow_auth.go+17−5
@@ -9,6 +9,7 @@ import ( · 8 unmodified lines
99 "io"
1010 "net/http"
1111 "net/url"
12+ "os"
1213 "strings"
1314 "time"
1415
@@ -28,10 +29,21 @@ const ( · 13 unmodified lines
2829 iFlowAPIKeyEndpoint = "https://platform.iflow.cn/api/openapi/apikey"
2930
3031 // Client credentials provided by iFlow for the Code Assist integration.
31− iFlowOAuthClientID = "10009311001"
32− iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
32+ iFlowOAuthClientID = "10009311001"
33+ // Default client secret (can be overridden via IFLOW_CLIENT_SECRET env var)
34+ defaultIFlowClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
3335 )
3436
37+// getIFlowClientSecret returns the iFlow OAuth client secret.
38+// It first checks the IFLOW_CLIENT_SECRET environment variable,
39+// falling back to the default value if not set.
40+func getIFlowClientSecret() string {
41+ if secret := os.Getenv("IFLOW_CLIENT_SECRET"); secret != "" {
42+ return secret
43+ }
44+ return defaultIFlowClientSecret
45+}
46+
3547 // DefaultAPIBaseURL is the canonical chat completions endpoint.
3648 const DefaultAPIBaseURL = "https://apis.iflow.cn/v1"
3749
@@ -72,7 +84,7 @@ func (ia *IFlowAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectUR · 34 unmodified lines
7284 form.Set("code", code)
7385 form.Set("redirect_uri", redirectURI)
7486 form.Set("client_id", iFlowOAuthClientID)
75− form.Set("client_secret", iFlowOAuthClientSecret)
87+ form.Set("client_secret", getIFlowClientSecret())
7688
7789 req, err := ia.newTokenRequest(ctx, form)
7890 if err != nil {
@@ -88,7 +100,7 @@ func (ia *IFlowAuth) RefreshTokens(ctx context.Context, refreshToken string) (*I · 9 unmodified lines
88100 form.Set("grant_type", "refresh_token")
89101 form.Set("refresh_token", refreshToken)
90102 form.Set("client_id", iFlowOAuthClientID)
91− form.Set("client_secret", iFlowOAuthClientSecret)
103+ form.Set("client_secret", getIFlowClientSecret())
92104
93105 req, err := ia.newTokenRequest(ctx, form)
94106 if err != nil {
@@ -104,7 +116,7 @@ func (ia *IFlowAuth) newTokenRequest(ctx context.Context, form url.Values) (*htt · 9 unmodified lines
104116 return nil, fmt.Errorf("iflow token: create request failed: %w", err)
105117 }
106118
107− basic := base64.StdEncoding.EncodeToString([]byte(iFlowOAuthClientID + ":" + iFlowOAuthClientSecret))
119+ basic := base64.StdEncoding.EncodeToString([]byte(iFlowOAuthClientID + ":" + getIFlowClientSecret()))
108120 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
109121 req.Header.Set("Accept", "application/json")
110122 req.Header.Set("Authorization", "Basic "+basic)
internal/auth/kiro/aws.go+305−0
@@ -0,0 +1,305 @@
1+// Package kiro provides authentication functionality for AWS CodeWhisperer (Kiro) API.
2+// It includes interfaces and implementations for token storage and authentication methods.
3+package kiro
4+
5+import (
6+ "encoding/base64"
7+ "encoding/json"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+)
13+
14+// PKCECodes holds PKCE verification codes for OAuth2 PKCE flow
15+type PKCECodes struct {
16+ // CodeVerifier is the cryptographically random string used to correlate
17+ // the authorization request to the token request
18+ CodeVerifier string `json:"code_verifier"`
19+ // CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
20+ CodeChallenge string `json:"code_challenge"`
21+}
22+
23+// KiroTokenData holds OAuth token information from AWS CodeWhisperer (Kiro)
24+type KiroTokenData struct {
25+ // AccessToken is the OAuth2 access token for API access
26+ AccessToken string `json:"accessToken"`
27+ // RefreshToken is used to obtain new access tokens
28+ RefreshToken string `json:"refreshToken"`
29+ // ProfileArn is the AWS CodeWhisperer profile ARN
30+ ProfileArn string `json:"profileArn"`
31+ // ExpiresAt is the timestamp when the token expires
32+ ExpiresAt string `json:"expiresAt"`
33+ // AuthMethod indicates the authentication method used (e.g., "builder-id", "social")
34+ AuthMethod string `json:"authMethod"`
35+ // Provider indicates the OAuth provider (e.g., "AWS", "Google")
36+ Provider string `json:"provider"`
37+ // ClientID is the OIDC client ID (needed for token refresh)
38+ ClientID string `json:"clientId,omitempty"`
39+ // ClientSecret is the OIDC client secret (needed for token refresh)
40+ ClientSecret string `json:"clientSecret,omitempty"`
41+ // Email is the user's email address (used for file naming)
42+ Email string `json:"email,omitempty"`
43+ // StartURL is the IDC/Identity Center start URL (only for IDC auth method)
44+ StartURL string `json:"startUrl,omitempty"`
45+ // Region is the AWS region for IDC authentication (only for IDC auth method)
46+ Region string `json:"region,omitempty"`
47+}
48+
49+// KiroAuthBundle aggregates authentication data after OAuth flow completion
50+type KiroAuthBundle struct {
51+ // TokenData contains the OAuth tokens from the authentication flow
52+ TokenData KiroTokenData `json:"token_data"`
53+ // LastRefresh is the timestamp of the last token refresh
54+ LastRefresh string `json:"last_refresh"`
55+}
56+
57+// KiroUsageInfo represents usage information from CodeWhisperer API
58+type KiroUsageInfo struct {
59+ // SubscriptionTitle is the subscription plan name (e.g., "KIRO FREE")
60+ SubscriptionTitle string `json:"subscription_title"`
61+ // CurrentUsage is the current credit usage
62+ CurrentUsage float64 `json:"current_usage"`
63+ // UsageLimit is the maximum credit limit
64+ UsageLimit float64 `json:"usage_limit"`
65+ // NextReset is the timestamp of the next usage reset
66+ NextReset string `json:"next_reset"`
67+}
68+
69+// KiroModel represents a model available through the CodeWhisperer API
70+type KiroModel struct {
71+ // ModelID is the unique identifier for the model
72+ ModelID string `json:"modelId"`
73+ // ModelName is the human-readable name
74+ ModelName string `json:"modelName"`
75+ // Description is the model description
76+ Description string `json:"description"`
77+ // RateMultiplier is the credit multiplier for this model
78+ RateMultiplier float64 `json:"rateMultiplier"`
79+ // RateUnit is the unit for rate calculation (e.g., "credit")
80+ RateUnit string `json:"rateUnit"`
81+ // MaxInputTokens is the maximum input token limit
82+ MaxInputTokens int `json:"maxInputTokens,omitempty"`
83+}
84+
85+// KiroIDETokenFile is the default path to Kiro IDE's token file
86+const KiroIDETokenFile = ".aws/sso/cache/kiro-auth-token.json"
87+
88+// LoadKiroIDEToken loads token data from Kiro IDE's token file.
89+func LoadKiroIDEToken() (*KiroTokenData, error) {
90+ homeDir, err := os.UserHomeDir()
91+ if err != nil {
92+ return nil, fmt.Errorf("failed to get home directory: %w", err)
93+ }
94+
95+ tokenPath := filepath.Join(homeDir, KiroIDETokenFile)
96+ data, err := os.ReadFile(tokenPath)
97+ if err != nil {
98+ return nil, fmt.Errorf("failed to read Kiro IDE token file (%s): %w", tokenPath, err)
99+ }
100+
101+ var token KiroTokenData
102+ if err := json.Unmarshal(data, &token); err != nil {
103+ return nil, fmt.Errorf("failed to parse Kiro IDE token: %w", err)
104+ }
105+
106+ if token.AccessToken == "" {
107+ return nil, fmt.Errorf("access token is empty in Kiro IDE token file")
108+ }
109+
110+ return &token, nil
111+}
112+
113+// LoadKiroTokenFromPath loads token data from a custom path.
114+// This supports multiple accounts by allowing different token files.
115+func LoadKiroTokenFromPath(tokenPath string) (*KiroTokenData, error) {
116+ // Expand ~ to home directory
117+ if len(tokenPath) > 0 && tokenPath[0] == '~' {
118+ homeDir, err := os.UserHomeDir()
119+ if err != nil {
120+ return nil, fmt.Errorf("failed to get home directory: %w", err)
121+ }
122+ tokenPath = filepath.Join(homeDir, tokenPath[1:])
123+ }
124+
125+ data, err := os.ReadFile(tokenPath)
126+ if err != nil {
127+ return nil, fmt.Errorf("failed to read token file (%s): %w", tokenPath, err)
128+ }
129+
130+ var token KiroTokenData
131+ if err := json.Unmarshal(data, &token); err != nil {
132+ return nil, fmt.Errorf("failed to parse token file: %w", err)
133+ }
134+
135+ if token.AccessToken == "" {
136+ return nil, fmt.Errorf("access token is empty in token file")
137+ }
138+
139+ return &token, nil
140+}
141+
142+// ListKiroTokenFiles lists all Kiro token files in the cache directory.
143+// This supports multiple accounts by finding all token files.
144+func ListKiroTokenFiles() ([]string, error) {
145+ homeDir, err := os.UserHomeDir()
146+ if err != nil {
147+ return nil, fmt.Errorf("failed to get home directory: %w", err)
148+ }
149+
150+ cacheDir := filepath.Join(homeDir, ".aws", "sso", "cache")
151+
152+ // Check if directory exists
153+ if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
154+ return nil, nil // No token files
155+ }
156+
157+ entries, err := os.ReadDir(cacheDir)
158+ if err != nil {
159+ return nil, fmt.Errorf("failed to read cache directory: %w", err)
160+ }
161+
162+ var tokenFiles []string
163+ for _, entry := range entries {
164+ if entry.IsDir() {
165+ continue
166+ }
167+ name := entry.Name()
168+ // Look for kiro token files only (avoid matching unrelated AWS SSO cache files)
169+ if strings.HasSuffix(name, ".json") && strings.HasPrefix(name, "kiro") {
170+ tokenFiles = append(tokenFiles, filepath.Join(cacheDir, name))
171+ }
172+ }
173+
174+ return tokenFiles, nil
175+}
176+
177+// LoadAllKiroTokens loads all Kiro tokens from the cache directory.
178+// This supports multiple accounts.
179+func LoadAllKiroTokens() ([]*KiroTokenData, error) {
180+ files, err := ListKiroTokenFiles()
181+ if err != nil {
182+ return nil, err
183+ }
184+
185+ var tokens []*KiroTokenData
186+ for _, file := range files {
187+ token, err := LoadKiroTokenFromPath(file)
188+ if err != nil {
189+ // Skip invalid token files
190+ continue
191+ }
192+ tokens = append(tokens, token)
193+ }
194+
195+ return tokens, nil
196+}
197+
198+// JWTClaims represents the claims we care about from a JWT token.
199+// JWT tokens from Kiro/AWS contain user information in the payload.
200+type JWTClaims struct {
201+ Email string `json:"email,omitempty"`
202+ Sub string `json:"sub,omitempty"`
203+ PreferredUser string `json:"preferred_username,omitempty"`
204+ Name string `json:"name,omitempty"`
205+ Iss string `json:"iss,omitempty"`
206+}
207+
208+// ExtractEmailFromJWT extracts the user's email from a JWT access token.
209+// JWT tokens typically have format: header.payload.signature
210+// The payload is base64url-encoded JSON containing user claims.
211+func ExtractEmailFromJWT(accessToken string) string {
212+ if accessToken == "" {
213+ return ""
214+ }
215+
216+ // JWT format: header.payload.signature
217+ parts := strings.Split(accessToken, ".")
218+ if len(parts) != 3 {
219+ return ""
220+ }
221+
222+ // Decode the payload (second part)
223+ payload := parts[1]
224+
225+ // Add padding if needed (base64url requires padding)
226+ switch len(payload) % 4 {
227+ case 2:
228+ payload += "=="
229+ case 3:
230+ payload += "="
231+ }
232+
233+ decoded, err := base64.URLEncoding.DecodeString(payload)
234+ if err != nil {
235+ // Try RawURLEncoding (no padding)
236+ decoded, err = base64.RawURLEncoding.DecodeString(parts[1])
237+ if err != nil {
238+ return ""
239+ }
240+ }
241+
242+ var claims JWTClaims
243+ if err := json.Unmarshal(decoded, &claims); err != nil {
244+ return ""
245+ }
246+
247+ // Return email if available
248+ if claims.Email != "" {
249+ return claims.Email
250+ }
251+
252+ // Fallback to preferred_username (some providers use this)
253+ if claims.PreferredUser != "" && strings.Contains(claims.PreferredUser, "@") {
254+ return claims.PreferredUser
255+ }
256+
257+ // Fallback to sub if it looks like an email
258+ if claims.Sub != "" && strings.Contains(claims.Sub, "@") {
259+ return claims.Sub
260+ }
261+
262+ return ""
263+}
264+
265+// SanitizeEmailForFilename sanitizes an email address for use in a filename.
266+// Replaces special characters with underscores and prevents path traversal attacks.
267+// Also handles URL-encoded characters to prevent encoded path traversal attempts.
268+func SanitizeEmailForFilename(email string) string {
269+ if email == "" {
270+ return ""
271+ }
272+
273+ result := email
274+
275+ // First, handle URL-encoded path traversal attempts (%2F, %2E, %5C, etc.)
276+ // This prevents encoded characters from bypassing the sanitization.
277+ // Note: We replace % last to catch any remaining encodings including double-encoding (%252F)
278+ result = strings.ReplaceAll(result, "%2F", "_") // /
279+ result = strings.ReplaceAll(result, "%2f", "_")
280+ result = strings.ReplaceAll(result, "%5C", "_") // \
281+ result = strings.ReplaceAll(result, "%5c", "_")
282+ result = strings.ReplaceAll(result, "%2E", "_") // .
283+ result = strings.ReplaceAll(result, "%2e", "_")
284+ result = strings.ReplaceAll(result, "%00", "_") // null byte
285+ result = strings.ReplaceAll(result, "%", "_") // Catch remaining % to prevent double-encoding attacks
286+
287+ // Replace characters that are problematic in filenames
288+ // Keep @ and . in middle but replace other special characters
289+ for _, char := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", " ", "\x00"} {
290+ result = strings.ReplaceAll(result, char, "_")
291+ }
292+
293+ // Prevent path traversal: replace leading dots in each path component
294+ // This handles cases like "../../../etc/passwd" → "_.._.._.._etc_passwd"
295+ parts := strings.Split(result, "_")
296+ for i, part := range parts {
297+ for strings.HasPrefix(part, ".") {
298+ part = "_" + part[1:]
299+ }
300+ parts[i] = part
301+ }
302+ result = strings.Join(parts, "_")
303+
304+ return result
305+}
internal/auth/kiro/aws_auth.go+314−0
@@ -0,0 +1,314 @@
1+// Package kiro provides OAuth2 authentication functionality for AWS CodeWhisperer (Kiro) API.
2+// This package implements token loading, refresh, and API communication with CodeWhisperer.
3+package kiro
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "time"
15+
16+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
17+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
18+ log "github.com/sirupsen/logrus"
19+)
20+
21+const (
22+ // awsKiroEndpoint is used for CodeWhisperer management APIs (GetUsageLimits, ListProfiles, etc.)
23+ // Note: This is different from the Amazon Q streaming endpoint (q.us-east-1.amazonaws.com)
24+ // used in kiro_executor.go for GenerateAssistantResponse. Both endpoints are correct
25+ // for their respective API operations.
26+ awsKiroEndpoint = "https://codewhisperer.us-east-1.amazonaws.com"
27+ defaultTokenFile = "~/.aws/sso/cache/kiro-auth-token.json"
28+ targetGetUsage = "AmazonCodeWhispererService.GetUsageLimits"
29+ targetListModels = "AmazonCodeWhispererService.ListAvailableModels"
30+ targetGenerateChat = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"
31+)
32+
33+// KiroAuth handles AWS CodeWhisperer authentication and API communication.
34+// It provides methods for loading tokens, refreshing expired tokens,
35+// and communicating with the CodeWhisperer API.
36+type KiroAuth struct {
37+ httpClient *http.Client
38+ endpoint string
39+}
40+
41+// NewKiroAuth creates a new Kiro authentication service.
42+// It initializes the HTTP client with proxy settings from the configuration.
43+//
44+// Parameters:
45+// - cfg: The application configuration containing proxy settings
46+//
47+// Returns:
48+// - *KiroAuth: A new Kiro authentication service instance
49+func NewKiroAuth(cfg *config.Config) *KiroAuth {
50+ return &KiroAuth{
51+ httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 120 * time.Second}),
52+ endpoint: awsKiroEndpoint,
53+ }
54+}
55+
56+// LoadTokenFromFile loads token data from a file path.
57+// This method reads and parses the token file, expanding ~ to the home directory.
58+//
59+// Parameters:
60+// - tokenFile: Path to the token file (supports ~ expansion)
61+//
62+// Returns:
63+// - *KiroTokenData: The parsed token data
64+// - error: An error if file reading or parsing fails
65+func (k *KiroAuth) LoadTokenFromFile(tokenFile string) (*KiroTokenData, error) {
66+ // Expand ~ to home directory
67+ if strings.HasPrefix(tokenFile, "~") {
68+ home, err := os.UserHomeDir()
69+ if err != nil {
70+ return nil, fmt.Errorf("failed to get home directory: %w", err)
71+ }
72+ tokenFile = filepath.Join(home, tokenFile[1:])
73+ }
74+
75+ data, err := os.ReadFile(tokenFile)
76+ if err != nil {
77+ return nil, fmt.Errorf("failed to read token file: %w", err)
78+ }
79+
80+ var tokenData KiroTokenData
81+ if err := json.Unmarshal(data, &tokenData); err != nil {
82+ return nil, fmt.Errorf("failed to parse token file: %w", err)
83+ }
84+
85+ return &tokenData, nil
86+}
87+
88+// IsTokenExpired checks if the token has expired.
89+// This method parses the expiration timestamp and compares it with the current time.
90+//
91+// Parameters:
92+// - tokenData: The token data to check
93+//
94+// Returns:
95+// - bool: True if the token has expired, false otherwise
96+func (k *KiroAuth) IsTokenExpired(tokenData *KiroTokenData) bool {
97+ if tokenData.ExpiresAt == "" {
98+ return true
99+ }
100+
101+ expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
102+ if err != nil {
103+ // Try alternate format
104+ expiresAt, err = time.Parse("2006-01-02T15:04:05.000Z", tokenData.ExpiresAt)
105+ if err != nil {
106+ return true
107+ }
108+ }
109+
110+ return time.Now().After(expiresAt)
111+}
112+
113+// makeRequest sends a request to the CodeWhisperer API.
114+// This is an internal method for making authenticated API calls.
115+//
116+// Parameters:
117+// - ctx: The context for the request
118+// - target: The API target (e.g., "AmazonCodeWhispererService.GetUsageLimits")
119+// - accessToken: The OAuth access token
120+// - payload: The request payload
121+//
122+// Returns:
123+// - []byte: The response body
124+// - error: An error if the request fails
125+func (k *KiroAuth) makeRequest(ctx context.Context, target string, accessToken string, payload interface{}) ([]byte, error) {
126+ jsonBody, err := json.Marshal(payload)
127+ if err != nil {
128+ return nil, fmt.Errorf("failed to marshal request: %w", err)
129+ }
130+
131+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, k.endpoint, strings.NewReader(string(jsonBody)))
132+ if err != nil {
133+ return nil, fmt.Errorf("failed to create request: %w", err)
134+ }
135+
136+ req.Header.Set("Content-Type", "application/x-amz-json-1.0")
137+ req.Header.Set("x-amz-target", target)
138+ req.Header.Set("Authorization", "Bearer "+accessToken)
139+ req.Header.Set("Accept", "application/json")
140+
141+ resp, err := k.httpClient.Do(req)
142+ if err != nil {
143+ return nil, fmt.Errorf("request failed: %w", err)
144+ }
145+ defer func() {
146+ if errClose := resp.Body.Close(); errClose != nil {
147+ log.Errorf("failed to close response body: %v", errClose)
148+ }
149+ }()
150+
151+ body, err := io.ReadAll(resp.Body)
152+ if err != nil {
153+ return nil, fmt.Errorf("failed to read response: %w", err)
154+ }
155+
156+ if resp.StatusCode != http.StatusOK {
157+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
158+ }
159+
160+ return body, nil
161+}
162+
163+// GetUsageLimits retrieves usage information from the CodeWhisperer API.
164+// This method fetches the current usage statistics and subscription information.
165+//
166+// Parameters:
167+// - ctx: The context for the request
168+// - tokenData: The token data containing access token and profile ARN
169+//
170+// Returns:
171+// - *KiroUsageInfo: The usage information
172+// - error: An error if the request fails
173+func (k *KiroAuth) GetUsageLimits(ctx context.Context, tokenData *KiroTokenData) (*KiroUsageInfo, error) {
174+ payload := map[string]interface{}{
175+ "origin": "AI_EDITOR",
176+ "profileArn": tokenData.ProfileArn,
177+ "resourceType": "AGENTIC_REQUEST",
178+ }
179+
180+ body, err := k.makeRequest(ctx, targetGetUsage, tokenData.AccessToken, payload)
181+ if err != nil {
182+ return nil, err
183+ }
184+
185+ var result struct {
186+ SubscriptionInfo struct {
187+ SubscriptionTitle string `json:"subscriptionTitle"`
188+ } `json:"subscriptionInfo"`
189+ UsageBreakdownList []struct {
190+ CurrentUsageWithPrecision float64 `json:"currentUsageWithPrecision"`
191+ UsageLimitWithPrecision float64 `json:"usageLimitWithPrecision"`
192+ } `json:"usageBreakdownList"`
193+ NextDateReset float64 `json:"nextDateReset"`
194+ }
195+
196+ if err := json.Unmarshal(body, &result); err != nil {
197+ return nil, fmt.Errorf("failed to parse usage response: %w", err)
198+ }
199+
200+ usage := &KiroUsageInfo{
201+ SubscriptionTitle: result.SubscriptionInfo.SubscriptionTitle,
202+ NextReset: fmt.Sprintf("%v", result.NextDateReset),
203+ }
204+
205+ if len(result.UsageBreakdownList) > 0 {
206+ usage.CurrentUsage = result.UsageBreakdownList[0].CurrentUsageWithPrecision
207+ usage.UsageLimit = result.UsageBreakdownList[0].UsageLimitWithPrecision
208+ }
209+
210+ return usage, nil
211+}
212+
213+// ListAvailableModels retrieves available models from the CodeWhisperer API.
214+// This method fetches the list of AI models available for the authenticated user.
215+//
216+// Parameters:
217+// - ctx: The context for the request
218+// - tokenData: The token data containing access token and profile ARN
219+//
220+// Returns:
221+// - []*KiroModel: The list of available models
222+// - error: An error if the request fails
223+func (k *KiroAuth) ListAvailableModels(ctx context.Context, tokenData *KiroTokenData) ([]*KiroModel, error) {
224+ payload := map[string]interface{}{
225+ "origin": "AI_EDITOR",
226+ "profileArn": tokenData.ProfileArn,
227+ }
228+
229+ body, err := k.makeRequest(ctx, targetListModels, tokenData.AccessToken, payload)
230+ if err != nil {
231+ return nil, err
232+ }
233+
234+ var result struct {
235+ Models []struct {
236+ ModelID string `json:"modelId"`
237+ ModelName string `json:"modelName"`
238+ Description string `json:"description"`
239+ RateMultiplier float64 `json:"rateMultiplier"`
240+ RateUnit string `json:"rateUnit"`
241+ TokenLimits struct {
242+ MaxInputTokens int `json:"maxInputTokens"`
243+ } `json:"tokenLimits"`
244+ } `json:"models"`
245+ }
246+
247+ if err := json.Unmarshal(body, &result); err != nil {
248+ return nil, fmt.Errorf("failed to parse models response: %w", err)
249+ }
250+
251+ models := make([]*KiroModel, 0, len(result.Models))
252+ for _, m := range result.Models {
253+ models = append(models, &KiroModel{
254+ ModelID: m.ModelID,
255+ ModelName: m.ModelName,
256+ Description: m.Description,
257+ RateMultiplier: m.RateMultiplier,
258+ RateUnit: m.RateUnit,
259+ MaxInputTokens: m.TokenLimits.MaxInputTokens,
260+ })
261+ }
262+
263+ return models, nil
264+}
265+
266+// CreateTokenStorage creates a new KiroTokenStorage from token data.
267+// This method converts the token data into a storage structure suitable for persistence.
268+//
269+// Parameters:
270+// - tokenData: The token data to convert
271+//
272+// Returns:
273+// - *KiroTokenStorage: A new token storage instance
274+func (k *KiroAuth) CreateTokenStorage(tokenData *KiroTokenData) *KiroTokenStorage {
275+ return &KiroTokenStorage{
276+ AccessToken: tokenData.AccessToken,
277+ RefreshToken: tokenData.RefreshToken,
278+ ProfileArn: tokenData.ProfileArn,
279+ ExpiresAt: tokenData.ExpiresAt,
280+ AuthMethod: tokenData.AuthMethod,
281+ Provider: tokenData.Provider,
282+ LastRefresh: time.Now().Format(time.RFC3339),
283+ }
284+}
285+
286+// ValidateToken checks if the token is valid by making a test API call.
287+// This method verifies the token by attempting to fetch usage limits.
288+//
289+// Parameters:
290+// - ctx: The context for the request
291+// - tokenData: The token data to validate
292+//
293+// Returns:
294+// - error: An error if the token is invalid
295+func (k *KiroAuth) ValidateToken(ctx context.Context, tokenData *KiroTokenData) error {
296+ _, err := k.GetUsageLimits(ctx, tokenData)
297+ return err
298+}
299+
300+// UpdateTokenStorage updates an existing token storage with new token data.
301+// This method refreshes the token storage with newly obtained access and refresh tokens.
302+//
303+// Parameters:
304+// - storage: The existing token storage to update
305+// - tokenData: The new token data to apply
306+func (k *KiroAuth) UpdateTokenStorage(storage *KiroTokenStorage, tokenData *KiroTokenData) {
307+ storage.AccessToken = tokenData.AccessToken
308+ storage.RefreshToken = tokenData.RefreshToken
309+ storage.ProfileArn = tokenData.ProfileArn
310+ storage.ExpiresAt = tokenData.ExpiresAt
311+ storage.AuthMethod = tokenData.AuthMethod
312+ storage.Provider = tokenData.Provider
313+ storage.LastRefresh = time.Now().Format(time.RFC3339)
314+}
internal/auth/kiro/aws_test.go+161−0
@@ -0,0 +1,161 @@
1+package kiro
2+
3+import (
4+ "encoding/base64"
5+ "encoding/json"
6+ "testing"
7+)
8+
9+func TestExtractEmailFromJWT(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ token string
13+ expected string
14+ }{
15+ {
16+ name: "Empty token",
17+ token: "",
18+ expected: "",
19+ },
20+ {
21+ name: "Invalid token format",
22+ token: "not.a.valid.jwt",
23+ expected: "",
24+ },
25+ {
26+ name: "Invalid token - not base64",
27+ token: "xxx.yyy.zzz",
28+ expected: "",
29+ },
30+ {
31+ name: "Valid JWT with email",
32+ token: createTestJWT(map[string]any{"email": "test@example.com", "sub": "user123"}),
33+ expected: "test@example.com",
34+ },
35+ {
36+ name: "JWT without email but with preferred_username",
37+ token: createTestJWT(map[string]any{"preferred_username": "user@domain.com", "sub": "user123"}),
38+ expected: "user@domain.com",
39+ },
40+ {
41+ name: "JWT with email-like sub",
42+ token: createTestJWT(map[string]any{"sub": "another@test.com"}),
43+ expected: "another@test.com",
44+ },
45+ {
46+ name: "JWT without any email fields",
47+ token: createTestJWT(map[string]any{"sub": "user123", "name": "Test User"}),
48+ expected: "",
49+ },
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ result := ExtractEmailFromJWT(tt.token)
55+ if result != tt.expected {
56+ t.Errorf("ExtractEmailFromJWT() = %q, want %q", result, tt.expected)
57+ }
58+ })
59+ }
60+}
61+
62+func TestSanitizeEmailForFilename(t *testing.T) {
63+ tests := []struct {
64+ name string
65+ email string
66+ expected string
67+ }{
68+ {
69+ name: "Empty email",
70+ email: "",
71+ expected: "",
72+ },
73+ {
74+ name: "Simple email",
75+ email: "user@example.com",
76+ expected: "user@example.com",
77+ },
78+ {
79+ name: "Email with space",
80+ email: "user name@example.com",
81+ expected: "user_name@example.com",
82+ },
83+ {
84+ name: "Email with special chars",
85+ email: "user:name@example.com",
86+ expected: "user_name@example.com",
87+ },
88+ {
89+ name: "Email with multiple special chars",
90+ email: "user/name:test@example.com",
91+ expected: "user_name_test@example.com",
92+ },
93+ {
94+ name: "Path traversal attempt",
95+ email: "../../../etc/passwd",
96+ expected: "_.__.__._etc_passwd",
97+ },
98+ {
99+ name: "Path traversal with backslash",
100+ email: `..\..\..\..\windows\system32`,
101+ expected: "_.__.__.__._windows_system32",
102+ },
103+ {
104+ name: "Null byte injection attempt",
105+ email: "user\x00@evil.com",
106+ expected: "user_@evil.com",
107+ },
108+ // URL-encoded path traversal tests
109+ {
110+ name: "URL-encoded slash",
111+ email: "user%2Fpath@example.com",
112+ expected: "user_path@example.com",
113+ },
114+ {
115+ name: "URL-encoded backslash",
116+ email: "user%5Cpath@example.com",
117+ expected: "user_path@example.com",
118+ },
119+ {
120+ name: "URL-encoded dot",
121+ email: "%2E%2E%2Fetc%2Fpasswd",
122+ expected: "___etc_passwd",
123+ },
124+ {
125+ name: "URL-encoded null",
126+ email: "user%00@evil.com",
127+ expected: "user_@evil.com",
128+ },
129+ {
130+ name: "Double URL-encoding attack",
131+ email: "%252F%252E%252E",
132+ expected: "_252F_252E_252E", // % replaced with _, remaining chars preserved (safe)
133+ },
134+ {
135+ name: "Mixed case URL-encoding",
136+ email: "%2f%2F%5c%5C",
137+ expected: "____",
138+ },
139+ }
140+
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ result := SanitizeEmailForFilename(tt.email)
144+ if result != tt.expected {
145+ t.Errorf("SanitizeEmailForFilename() = %q, want %q", result, tt.expected)
146+ }
147+ })
148+ }
149+}
150+
151+// createTestJWT creates a test JWT token with the given claims
152+func createTestJWT(claims map[string]any) string {
153+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
154+
155+ payloadBytes, _ := json.Marshal(claims)
156+ payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
157+
158+ signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
159+
160+ return header + "." + payload + "." + signature
161+}
internal/auth/kiro/codewhisperer_client.go+166−0
@@ -0,0 +1,166 @@
1+// Package kiro provides CodeWhisperer API client for fetching user info.
2+package kiro
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "time"
11+
12+ "github.com/google/uuid"
13+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
15+ log "github.com/sirupsen/logrus"
16+)
17+
18+const (
19+ codeWhispererAPI = "https://codewhisperer.us-east-1.amazonaws.com"
20+ kiroVersion = "0.6.18"
21+)
22+
23+// CodeWhispererClient handles CodeWhisperer API calls.
24+type CodeWhispererClient struct {
25+ httpClient *http.Client
26+ machineID string
27+}
28+
29+// UsageLimitsResponse represents the getUsageLimits API response.
30+type UsageLimitsResponse struct {
31+ DaysUntilReset *int `json:"daysUntilReset,omitempty"`
32+ NextDateReset *float64 `json:"nextDateReset,omitempty"`
33+ UserInfo *UserInfo `json:"userInfo,omitempty"`
34+ SubscriptionInfo *SubscriptionInfo `json:"subscriptionInfo,omitempty"`
35+ UsageBreakdownList []UsageBreakdown `json:"usageBreakdownList,omitempty"`
36+}
37+
38+// UserInfo contains user information from the API.
39+type UserInfo struct {
40+ Email string `json:"email,omitempty"`
41+ UserID string `json:"userId,omitempty"`
42+}
43+
44+// SubscriptionInfo contains subscription details.
45+type SubscriptionInfo struct {
46+ SubscriptionTitle string `json:"subscriptionTitle,omitempty"`
47+ Type string `json:"type,omitempty"`
48+}
49+
50+// UsageBreakdown contains usage details.
51+type UsageBreakdown struct {
52+ UsageLimit *int `json:"usageLimit,omitempty"`
53+ CurrentUsage *int `json:"currentUsage,omitempty"`
54+ UsageLimitWithPrecision *float64 `json:"usageLimitWithPrecision,omitempty"`
55+ CurrentUsageWithPrecision *float64 `json:"currentUsageWithPrecision,omitempty"`
56+ NextDateReset *float64 `json:"nextDateReset,omitempty"`
57+ DisplayName string `json:"displayName,omitempty"`
58+ ResourceType string `json:"resourceType,omitempty"`
59+}
60+
61+// NewCodeWhispererClient creates a new CodeWhisperer client.
62+func NewCodeWhispererClient(cfg *config.Config, machineID string) *CodeWhispererClient {
63+ client := &http.Client{Timeout: 30 * time.Second}
64+ if cfg != nil {
65+ client = util.SetProxy(&cfg.SDKConfig, client)
66+ }
67+ if machineID == "" {
68+ machineID = uuid.New().String()
69+ }
70+ return &CodeWhispererClient{
71+ httpClient: client,
72+ machineID: machineID,
73+ }
74+}
75+
76+// generateInvocationID generates a unique invocation ID.
77+func generateInvocationID() string {
78+ return uuid.New().String()
79+}
80+
81+// GetUsageLimits fetches usage limits and user info from CodeWhisperer API.
82+// This is the recommended way to get user email after login.
83+func (c *CodeWhispererClient) GetUsageLimits(ctx context.Context, accessToken string) (*UsageLimitsResponse, error) {
84+ url := fmt.Sprintf("%s/getUsageLimits?isEmailRequired=true&origin=AI_EDITOR&resourceType=AGENTIC_REQUEST", codeWhispererAPI)
85+
86+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
87+ if err != nil {
88+ return nil, fmt.Errorf("failed to create request: %w", err)
89+ }
90+
91+ // Set headers to match Kiro IDE
92+ xAmzUserAgent := fmt.Sprintf("aws-sdk-js/1.0.0 KiroIDE-%s-%s", kiroVersion, c.machineID)
93+ userAgent := fmt.Sprintf("aws-sdk-js/1.0.0 ua/2.1 os/windows lang/js md/nodejs#20.16.0 api/codewhispererruntime#1.0.0 m/E KiroIDE-%s-%s", kiroVersion, c.machineID)
94+
95+ req.Header.Set("Authorization", "Bearer "+accessToken)
96+ req.Header.Set("x-amz-user-agent", xAmzUserAgent)
97+ req.Header.Set("User-Agent", userAgent)
98+ req.Header.Set("amz-sdk-invocation-id", generateInvocationID())
99+ req.Header.Set("amz-sdk-request", "attempt=1; max=1")
100+ req.Header.Set("Connection", "close")
101+
102+ log.Debugf("codewhisperer: GET %s", url)
103+
104+ resp, err := c.httpClient.Do(req)
105+ if err != nil {
106+ return nil, fmt.Errorf("request failed: %w", err)
107+ }
108+ defer resp.Body.Close()
109+
110+ body, err := io.ReadAll(resp.Body)
111+ if err != nil {
112+ return nil, fmt.Errorf("failed to read response: %w", err)
113+ }
114+
115+ log.Debugf("codewhisperer: status=%d, body=%s", resp.StatusCode, string(body))
116+
117+ if resp.StatusCode != http.StatusOK {
118+ return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
119+ }
120+
121+ var result UsageLimitsResponse
122+ if err := json.Unmarshal(body, &result); err != nil {
123+ return nil, fmt.Errorf("failed to parse response: %w", err)
124+ }
125+
126+ return &result, nil
127+}
128+
129+// FetchUserEmailFromAPI fetches user email using CodeWhisperer getUsageLimits API.
130+// This is more reliable than JWT parsing as it uses the official API.
131+func (c *CodeWhispererClient) FetchUserEmailFromAPI(ctx context.Context, accessToken string) string {
132+ resp, err := c.GetUsageLimits(ctx, accessToken)
133+ if err != nil {
134+ log.Debugf("codewhisperer: failed to get usage limits: %v", err)
135+ return ""
136+ }
137+
138+ if resp.UserInfo != nil && resp.UserInfo.Email != "" {
139+ log.Debugf("codewhisperer: got email from API: %s", resp.UserInfo.Email)
140+ return resp.UserInfo.Email
141+ }
142+
143+ log.Debugf("codewhisperer: no email in response")
144+ return ""
145+}
146+
147+// FetchUserEmailWithFallback fetches user email with multiple fallback methods.
148+// Priority: 1. CodeWhisperer API 2. userinfo endpoint 3. JWT parsing
149+func FetchUserEmailWithFallback(ctx context.Context, cfg *config.Config, accessToken string) string {
150+ // Method 1: Try CodeWhisperer API (most reliable)
151+ cwClient := NewCodeWhispererClient(cfg, "")
152+ email := cwClient.FetchUserEmailFromAPI(ctx, accessToken)
153+ if email != "" {
154+ return email
155+ }
156+
157+ // Method 2: Try SSO OIDC userinfo endpoint
158+ ssoClient := NewSSOOIDCClient(cfg)
159+ email = ssoClient.FetchUserEmail(ctx, accessToken)
160+ if email != "" {
161+ return email
162+ }
163+
164+ // Method 3: Fallback to JWT parsing
165+ return ExtractEmailFromJWT(accessToken)
166+}
internal/auth/kiro/oauth.go+303−0
@@ -0,0 +1,303 @@
1+// Package kiro provides OAuth2 authentication for Kiro using native Google login.
2+package kiro
3+
4+import (
5+ "context"
6+ "crypto/rand"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "encoding/json"
10+ "fmt"
11+ "html"
12+ "io"
13+ "net"
14+ "net/http"
15+ "strings"
16+ "time"
17+
18+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
19+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
20+ log "github.com/sirupsen/logrus"
21+)
22+
23+const (
24+ // Kiro auth endpoint
25+ kiroAuthEndpoint = "https://prod.us-east-1.auth.desktop.kiro.dev"
26+
27+ // Default callback port
28+ defaultCallbackPort = 9876
29+
30+ // Auth timeout
31+ authTimeout = 10 * time.Minute
32+)
33+
34+// KiroTokenResponse represents the response from Kiro token endpoint.
35+type KiroTokenResponse struct {
36+ AccessToken string `json:"accessToken"`
37+ RefreshToken string `json:"refreshToken"`
38+ ProfileArn string `json:"profileArn"`
39+ ExpiresIn int `json:"expiresIn"`
40+}
41+
42+// KiroOAuth handles the OAuth flow for Kiro authentication.
43+type KiroOAuth struct {
44+ httpClient *http.Client
45+ cfg *config.Config
46+}
47+
48+// NewKiroOAuth creates a new Kiro OAuth handler.
49+func NewKiroOAuth(cfg *config.Config) *KiroOAuth {
50+ client := &http.Client{Timeout: 30 * time.Second}
51+ if cfg != nil {
52+ client = util.SetProxy(&cfg.SDKConfig, client)
53+ }
54+ return &KiroOAuth{
55+ httpClient: client,
56+ cfg: cfg,
57+ }
58+}
59+
60+// generateCodeVerifier generates a random code verifier for PKCE.
61+func generateCodeVerifier() (string, error) {
62+ b := make([]byte, 32)
63+ if _, err := rand.Read(b); err != nil {
64+ return "", err
65+ }
66+ return base64.RawURLEncoding.EncodeToString(b), nil
67+}
68+
69+// generateCodeChallenge generates the code challenge from verifier.
70+func generateCodeChallenge(verifier string) string {
71+ h := sha256.Sum256([]byte(verifier))
72+ return base64.RawURLEncoding.EncodeToString(h[:])
73+}
74+
75+// generateState generates a random state parameter.
76+func generateState() (string, error) {
77+ b := make([]byte, 16)
78+ if _, err := rand.Read(b); err != nil {
79+ return "", err
80+ }
81+ return base64.RawURLEncoding.EncodeToString(b), nil
82+}
83+
84+// AuthResult contains the authorization code and state from callback.
85+type AuthResult struct {
86+ Code string
87+ State string
88+ Error string
89+}
90+
91+// startCallbackServer starts a local HTTP server to receive the OAuth callback.
92+func (o *KiroOAuth) startCallbackServer(ctx context.Context, expectedState string) (string, <-chan AuthResult, error) {
93+ // Try to find an available port - use localhost like Kiro does
94+ listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", defaultCallbackPort))
95+ if err != nil {
96+ // Try with dynamic port (RFC 8252 allows dynamic ports for native apps)
97+ log.Warnf("kiro oauth: default port %d is busy, falling back to dynamic port", defaultCallbackPort)
98+ listener, err = net.Listen("tcp", "localhost:0")
99+ if err != nil {
100+ return "", nil, fmt.Errorf("failed to start callback server: %w", err)
101+ }
102+ }
103+
104+ port := listener.Addr().(*net.TCPAddr).Port
105+ // Use http scheme for local callback server
106+ redirectURI := fmt.Sprintf("http://localhost:%d/oauth/callback", port)
107+ resultChan := make(chan AuthResult, 1)
108+
109+ server := &http.Server{
110+ ReadHeaderTimeout: 10 * time.Second,
111+ }
112+
113+ mux := http.NewServeMux()
114+ mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
115+ code := r.URL.Query().Get("code")
116+ state := r.URL.Query().Get("state")
117+ errParam := r.URL.Query().Get("error")
118+
119+ if errParam != "" {
120+ w.Header().Set("Content-Type", "text/html")
121+ w.WriteHeader(http.StatusBadRequest)
122+ fmt.Fprintf(w, `<html><body><h1>Login Failed</h1><p>%s</p><p>You can close this window.</p></body></html>`, html.EscapeString(errParam))
123+ resultChan <- AuthResult{Error: errParam}
124+ return
125+ }
126+
127+ if state != expectedState {
128+ w.Header().Set("Content-Type", "text/html")
129+ w.WriteHeader(http.StatusBadRequest)
130+ fmt.Fprint(w, `<html><body><h1>Login Failed</h1><p>Invalid state parameter</p><p>You can close this window.</p></body></html>`)
131+ resultChan <- AuthResult{Error: "state mismatch"}
132+ return
133+ }
134+
135+ w.Header().Set("Content-Type", "text/html")
136+ fmt.Fprint(w, `<html><body><h1>Login Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`)
137+ resultChan <- AuthResult{Code: code, State: state}
138+ })
139+
140+ server.Handler = mux
141+
142+ go func() {
143+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
144+ log.Debugf("callback server error: %v", err)
145+ }
146+ }()
147+
148+ go func() {
149+ select {
150+ case <-ctx.Done():
151+ case <-time.After(authTimeout):
152+ case <-resultChan:
153+ }
154+ _ = server.Shutdown(context.Background())
155+ }()
156+
157+ return redirectURI, resultChan, nil
158+}
159+
160+// LoginWithBuilderID performs OAuth login with AWS Builder ID using device code flow.
161+func (o *KiroOAuth) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, error) {
162+ ssoClient := NewSSOOIDCClient(o.cfg)
163+ return ssoClient.LoginWithBuilderID(ctx)
164+}
165+
166+// LoginWithBuilderIDAuthCode performs OAuth login with AWS Builder ID using authorization code flow.
167+// This provides a better UX than device code flow as it uses automatic browser callback.
168+func (o *KiroOAuth) LoginWithBuilderIDAuthCode(ctx context.Context) (*KiroTokenData, error) {
169+ ssoClient := NewSSOOIDCClient(o.cfg)
170+ return ssoClient.LoginWithBuilderIDAuthCode(ctx)
171+}
172+
173+// exchangeCodeForToken exchanges the authorization code for tokens.
174+func (o *KiroOAuth) exchangeCodeForToken(ctx context.Context, code, codeVerifier, redirectURI string) (*KiroTokenData, error) {
175+ payload := map[string]string{
176+ "code": code,
177+ "code_verifier": codeVerifier,
178+ "redirect_uri": redirectURI,
179+ }
180+
181+ body, err := json.Marshal(payload)
182+ if err != nil {
183+ return nil, fmt.Errorf("failed to marshal request: %w", err)
184+ }
185+
186+ tokenURL := kiroAuthEndpoint + "/oauth/token"
187+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(string(body)))
188+ if err != nil {
189+ return nil, fmt.Errorf("failed to create request: %w", err)
190+ }
191+
192+ req.Header.Set("Content-Type", "application/json")
193+ req.Header.Set("User-Agent", "cli-proxy-api/1.0.0")
194+
195+ resp, err := o.httpClient.Do(req)
196+ if err != nil {
197+ return nil, fmt.Errorf("token request failed: %w", err)
198+ }
199+ defer resp.Body.Close()
200+
201+ respBody, err := io.ReadAll(resp.Body)
202+ if err != nil {
203+ return nil, fmt.Errorf("failed to read response: %w", err)
204+ }
205+
206+ if resp.StatusCode != http.StatusOK {
207+ log.Debugf("token exchange failed (status %d): %s", resp.StatusCode, string(respBody))
208+ return nil, fmt.Errorf("token exchange failed (status %d)", resp.StatusCode)
209+ }
210+
211+ var tokenResp KiroTokenResponse
212+ if err := json.Unmarshal(respBody, &tokenResp); err != nil {
213+ return nil, fmt.Errorf("failed to parse token response: %w", err)
214+ }
215+
216+ // Validate ExpiresIn - use default 1 hour if invalid
217+ expiresIn := tokenResp.ExpiresIn
218+ if expiresIn <= 0 {
219+ expiresIn = 3600
220+ }
221+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
222+
223+ return &KiroTokenData{
224+ AccessToken: tokenResp.AccessToken,
225+ RefreshToken: tokenResp.RefreshToken,
226+ ProfileArn: tokenResp.ProfileArn,
227+ ExpiresAt: expiresAt.Format(time.RFC3339),
228+ AuthMethod: "social",
229+ Provider: "", // Caller should preserve original provider
230+ }, nil
231+}
232+
233+// RefreshToken refreshes an expired access token.
234+func (o *KiroOAuth) RefreshToken(ctx context.Context, refreshToken string) (*KiroTokenData, error) {
235+ payload := map[string]string{
236+ "refreshToken": refreshToken,
237+ }
238+
239+ body, err := json.Marshal(payload)
240+ if err != nil {
241+ return nil, fmt.Errorf("failed to marshal request: %w", err)
242+ }
243+
244+ refreshURL := kiroAuthEndpoint + "/refreshToken"
245+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, refreshURL, strings.NewReader(string(body)))
246+ if err != nil {
247+ return nil, fmt.Errorf("failed to create request: %w", err)
248+ }
249+
250+ req.Header.Set("Content-Type", "application/json")
251+ req.Header.Set("User-Agent", "cli-proxy-api/1.0.0")
252+
253+ resp, err := o.httpClient.Do(req)
254+ if err != nil {
255+ return nil, fmt.Errorf("refresh request failed: %w", err)
256+ }
257+ defer resp.Body.Close()
258+
259+ respBody, err := io.ReadAll(resp.Body)
260+ if err != nil {
261+ return nil, fmt.Errorf("failed to read response: %w", err)
262+ }
263+
264+ if resp.StatusCode != http.StatusOK {
265+ log.Debugf("token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
266+ return nil, fmt.Errorf("token refresh failed (status %d)", resp.StatusCode)
267+ }
268+
269+ var tokenResp KiroTokenResponse
270+ if err := json.Unmarshal(respBody, &tokenResp); err != nil {
271+ return nil, fmt.Errorf("failed to parse token response: %w", err)
272+ }
273+
274+ // Validate ExpiresIn - use default 1 hour if invalid
275+ expiresIn := tokenResp.ExpiresIn
276+ if expiresIn <= 0 {
277+ expiresIn = 3600
278+ }
279+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
280+
281+ return &KiroTokenData{
282+ AccessToken: tokenResp.AccessToken,
283+ RefreshToken: tokenResp.RefreshToken,
284+ ProfileArn: tokenResp.ProfileArn,
285+ ExpiresAt: expiresAt.Format(time.RFC3339),
286+ AuthMethod: "social",
287+ Provider: "", // Caller should preserve original provider
288+ }, nil
289+}
290+
291+// LoginWithGoogle performs OAuth login with Google using Kiro's social auth.
292+// This uses a custom protocol handler (kiro://) to receive the callback.
293+func (o *KiroOAuth) LoginWithGoogle(ctx context.Context) (*KiroTokenData, error) {
294+ socialClient := NewSocialAuthClient(o.cfg)
295+ return socialClient.LoginWithGoogle(ctx)
296+}
297+
298+// LoginWithGitHub performs OAuth login with GitHub using Kiro's social auth.
299+// This uses a custom protocol handler (kiro://) to receive the callback.
300+func (o *KiroOAuth) LoginWithGitHub(ctx context.Context) (*KiroTokenData, error) {
301+ socialClient := NewSocialAuthClient(o.cfg)
302+ return socialClient.LoginWithGitHub(ctx)
303+}
internal/auth/kiro/protocol_handler.go+725−0
@@ -0,0 +1,725 @@
1+// Package kiro provides custom protocol handler registration for Kiro OAuth.
2+// This enables the CLI to intercept kiro:// URIs for social authentication (Google/GitHub).
3+package kiro
4+
5+import (
6+ "context"
7+ "fmt"
8+ "html"
9+ "net"
10+ "net/http"
11+ "net/url"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "runtime"
16+ "strings"
17+ "sync"
18+ "time"
19+
20+ log "github.com/sirupsen/logrus"
21+)
22+
23+const (
24+ // KiroProtocol is the custom URI scheme used by Kiro
25+ KiroProtocol = "kiro"
26+
27+ // KiroAuthority is the URI authority for authentication callbacks
28+ KiroAuthority = "kiro.kiroAgent"
29+
30+ // KiroAuthPath is the path for successful authentication
31+ KiroAuthPath = "/authenticate-success"
32+
33+ // KiroRedirectURI is the full redirect URI for social auth
34+ KiroRedirectURI = "kiro://kiro.kiroAgent/authenticate-success"
35+
36+ // DefaultHandlerPort is the default port for the local callback server
37+ DefaultHandlerPort = 19876
38+
39+ // HandlerTimeout is how long to wait for the OAuth callback
40+ HandlerTimeout = 10 * time.Minute
41+)
42+
43+// ProtocolHandler manages the custom kiro:// protocol handler for OAuth callbacks.
44+type ProtocolHandler struct {
45+ port int
46+ server *http.Server
47+ listener net.Listener
48+ resultChan chan *AuthCallback
49+ stopChan chan struct{}
50+ mu sync.Mutex
51+ running bool
52+}
53+
54+// AuthCallback contains the OAuth callback parameters.
55+type AuthCallback struct {
56+ Code string
57+ State string
58+ Error string
59+}
60+
61+// NewProtocolHandler creates a new protocol handler.
62+func NewProtocolHandler() *ProtocolHandler {
63+ return &ProtocolHandler{
64+ port: DefaultHandlerPort,
65+ resultChan: make(chan *AuthCallback, 1),
66+ stopChan: make(chan struct{}),
67+ }
68+}
69+
70+// Start starts the local callback server that receives redirects from the protocol handler.
71+func (h *ProtocolHandler) Start(ctx context.Context) (int, error) {
72+ h.mu.Lock()
73+ defer h.mu.Unlock()
74+
75+ if h.running {
76+ return h.port, nil
77+ }
78+
79+ // Drain any stale results from previous runs
80+ select {
81+ case <-h.resultChan:
82+ default:
83+ }
84+
85+ // Reset stopChan for reuse - close old channel first to unblock any waiting goroutines
86+ if h.stopChan != nil {
87+ select {
88+ case <-h.stopChan:
89+ // Already closed
90+ default:
91+ close(h.stopChan)
92+ }
93+ }
94+ h.stopChan = make(chan struct{})
95+
96+ // Try ports in known range (must match handler script port range)
97+ var listener net.Listener
98+ var err error
99+ portRange := []int{DefaultHandlerPort, DefaultHandlerPort + 1, DefaultHandlerPort + 2, DefaultHandlerPort + 3, DefaultHandlerPort + 4}
100+
101+ for _, port := range portRange {
102+ listener, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
103+ if err == nil {
104+ break
105+ }
106+ log.Debugf("kiro protocol handler: port %d busy, trying next", port)
107+ }
108+
109+ if listener == nil {
110+ return 0, fmt.Errorf("failed to start callback server: all ports %d-%d are busy", DefaultHandlerPort, DefaultHandlerPort+4)
111+ }
112+
113+ h.listener = listener
114+ h.port = listener.Addr().(*net.TCPAddr).Port
115+
116+ mux := http.NewServeMux()
117+ mux.HandleFunc("/oauth/callback", h.handleCallback)
118+
119+ h.server = &http.Server{
120+ Handler: mux,
121+ ReadHeaderTimeout: 10 * time.Second,
122+ }
123+
124+ go func() {
125+ if err := h.server.Serve(listener); err != nil && err != http.ErrServerClosed {
126+ log.Debugf("kiro protocol handler server error: %v", err)
127+ }
128+ }()
129+
130+ h.running = true
131+ log.Debugf("kiro protocol handler started on port %d", h.port)
132+
133+ // Auto-shutdown after context done, timeout, or explicit stop
134+ // Capture references to prevent race with new Start() calls
135+ currentStopChan := h.stopChan
136+ currentServer := h.server
137+ currentListener := h.listener
138+ go func() {
139+ select {
140+ case <-ctx.Done():
141+ case <-time.After(HandlerTimeout):
142+ case <-currentStopChan:
143+ return // Already stopped, exit goroutine
144+ }
145+ // Only stop if this is still the current server/listener instance
146+ h.mu.Lock()
147+ if h.server == currentServer && h.listener == currentListener {
148+ h.mu.Unlock()
149+ h.Stop()
150+ } else {
151+ h.mu.Unlock()
152+ }
153+ }()
154+
155+ return h.port, nil
156+}
157+
158+// Stop stops the callback server.
159+func (h *ProtocolHandler) Stop() {
160+ h.mu.Lock()
161+ defer h.mu.Unlock()
162+
163+ if !h.running {
164+ return
165+ }
166+
167+ // Signal the auto-shutdown goroutine to exit.
168+ // This select pattern is safe because stopChan is only modified while holding h.mu,
169+ // and we hold the lock here. The select prevents panic from double-close.
170+ select {
171+ case <-h.stopChan:
172+ // Already closed
173+ default:
174+ close(h.stopChan)
175+ }
176+
177+ if h.server != nil {
178+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
179+ defer cancel()
180+ _ = h.server.Shutdown(ctx)
181+ }
182+
183+ h.running = false
184+ log.Debug("kiro protocol handler stopped")
185+}
186+
187+// WaitForCallback waits for the OAuth callback and returns the result.
188+func (h *ProtocolHandler) WaitForCallback(ctx context.Context) (*AuthCallback, error) {
189+ select {
190+ case <-ctx.Done():
191+ return nil, ctx.Err()
192+ case <-time.After(HandlerTimeout):
193+ return nil, fmt.Errorf("timeout waiting for OAuth callback")
194+ case result := <-h.resultChan:
195+ return result, nil
196+ }
197+}
198+
199+// GetPort returns the port the handler is listening on.
200+func (h *ProtocolHandler) GetPort() int {
201+ return h.port
202+}
203+
204+// handleCallback processes the OAuth callback from the protocol handler script.
205+func (h *ProtocolHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
206+ code := r.URL.Query().Get("code")
207+ state := r.URL.Query().Get("state")
208+ errParam := r.URL.Query().Get("error")
209+
210+ result := &AuthCallback{
211+ Code: code,
212+ State: state,
213+ Error: errParam,
214+ }
215+
216+ // Send result
217+ select {
218+ case h.resultChan <- result:
219+ default:
220+ // Channel full, ignore duplicate callbacks
221+ }
222+
223+ // Send success response
224+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
225+ if errParam != "" {
226+ w.WriteHeader(http.StatusBadRequest)
227+ fmt.Fprintf(w, `<!DOCTYPE html>
228+<html>
229+<head><title>Login Failed</title></head>
230+<body>
231+<h1>Login Failed</h1>
232+<p>Error: %s</p>
233+<p>You can close this window.</p>
234+</body>
235+</html>`, html.EscapeString(errParam))
236+ } else {
237+ fmt.Fprint(w, `<!DOCTYPE html>
238+<html>
239+<head><title>Login Successful</title></head>
240+<body>
241+<h1>Login Successful!</h1>
242+<p>You can close this window and return to the terminal.</p>
243+<script>window.close();</script>
244+</body>
245+</html>`)
246+ }
247+}
248+
249+// IsProtocolHandlerInstalled checks if the kiro:// protocol handler is installed.
250+func IsProtocolHandlerInstalled() bool {
251+ switch runtime.GOOS {
252+ case "linux":
253+ return isLinuxHandlerInstalled()
254+ case "windows":
255+ return isWindowsHandlerInstalled()
256+ case "darwin":
257+ return isDarwinHandlerInstalled()
258+ default:
259+ return false
260+ }
261+}
262+
263+// InstallProtocolHandler installs the kiro:// protocol handler for the current platform.
264+func InstallProtocolHandler(handlerPort int) error {
265+ switch runtime.GOOS {
266+ case "linux":
267+ return installLinuxHandler(handlerPort)
268+ case "windows":
269+ return installWindowsHandler(handlerPort)
270+ case "darwin":
271+ return installDarwinHandler(handlerPort)
272+ default:
273+ return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
274+ }
275+}
276+
277+// UninstallProtocolHandler removes the kiro:// protocol handler.
278+func UninstallProtocolHandler() error {
279+ switch runtime.GOOS {
280+ case "linux":
281+ return uninstallLinuxHandler()
282+ case "windows":
283+ return uninstallWindowsHandler()
284+ case "darwin":
285+ return uninstallDarwinHandler()
286+ default:
287+ return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
288+ }
289+}
290+
291+// --- Linux Implementation ---
292+
293+func getLinuxDesktopPath() string {
294+ homeDir, _ := os.UserHomeDir()
295+ return filepath.Join(homeDir, ".local", "share", "applications", "kiro-oauth-handler.desktop")
296+}
297+
298+func getLinuxHandlerScriptPath() string {
299+ homeDir, _ := os.UserHomeDir()
300+ return filepath.Join(homeDir, ".local", "bin", "kiro-oauth-handler")
301+}
302+
303+func isLinuxHandlerInstalled() bool {
304+ desktopPath := getLinuxDesktopPath()
305+ _, err := os.Stat(desktopPath)
306+ return err == nil
307+}
308+
309+func installLinuxHandler(handlerPort int) error {
310+ // Create directories
311+ homeDir, err := os.UserHomeDir()
312+ if err != nil {
313+ return err
314+ }
315+
316+ binDir := filepath.Join(homeDir, ".local", "bin")
317+ appDir := filepath.Join(homeDir, ".local", "share", "applications")
318+
319+ if err := os.MkdirAll(binDir, 0755); err != nil {
320+ return fmt.Errorf("failed to create bin directory: %w", err)
321+ }
322+ if err := os.MkdirAll(appDir, 0755); err != nil {
323+ return fmt.Errorf("failed to create applications directory: %w", err)
324+ }
325+
326+ // Create handler script - tries multiple ports to handle dynamic port allocation
327+ scriptPath := getLinuxHandlerScriptPath()
328+ scriptContent := fmt.Sprintf(`#!/bin/bash
329+# Kiro OAuth Protocol Handler
330+# Handles kiro:// URIs - tries CLI first, then forwards to Kiro IDE
331+
332+URL="$1"
333+
334+# Check curl availability
335+if ! command -v curl &> /dev/null; then
336+ echo "Error: curl is required for Kiro OAuth handler" >&2
337+ exit 1
338+fi
339+
340+# Extract code and state from URL
341+[[ "$URL" =~ code=([^&]+) ]] && CODE="${BASH_REMATCH[1]}"
342+[[ "$URL" =~ state=([^&]+) ]] && STATE="${BASH_REMATCH[1]}"
343+[[ "$URL" =~ error=([^&]+) ]] && ERROR="${BASH_REMATCH[1]}"
344+
345+# Try CLI proxy on multiple possible ports (default + dynamic range)
346+CLI_OK=0
347+for PORT in %d %d %d %d %d; do
348+ if [ -n "$ERROR" ]; then
349+ curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?error=$ERROR" && CLI_OK=1 && break
350+ elif [ -n "$CODE" ] && [ -n "$STATE" ]; then
351+ curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?code=$CODE&state=$STATE" && CLI_OK=1 && break
352+ fi
353+done
354+
355+# If CLI not available, forward to Kiro IDE
356+if [ $CLI_OK -eq 0 ] && [ -x "/usr/share/kiro/kiro" ]; then
357+ /usr/share/kiro/kiro --open-url "$URL" &
358+fi
359+`, handlerPort, handlerPort+1, handlerPort+2, handlerPort+3, handlerPort+4)
360+
361+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
362+ return fmt.Errorf("failed to write handler script: %w", err)
363+ }
364+
365+ // Create .desktop file
366+ desktopPath := getLinuxDesktopPath()
367+ desktopContent := fmt.Sprintf(`[Desktop Entry]
368+Name=Kiro OAuth Handler
369+Comment=Handle kiro:// protocol for CLI Proxy API authentication
370+Exec=%s %%u
371+Type=Application
372+Terminal=false
373+NoDisplay=true
374+MimeType=x-scheme-handler/kiro;
375+Categories=Utility;
376+`, scriptPath)
377+
378+ if err := os.WriteFile(desktopPath, []byte(desktopContent), 0644); err != nil {
379+ return fmt.Errorf("failed to write desktop file: %w", err)
380+ }
381+
382+ // Register handler with xdg-mime
383+ cmd := exec.Command("xdg-mime", "default", "kiro-oauth-handler.desktop", "x-scheme-handler/kiro")
384+ if err := cmd.Run(); err != nil {
385+ log.Warnf("xdg-mime registration failed (may need manual setup): %v", err)
386+ }
387+
388+ // Update desktop database
389+ cmd = exec.Command("update-desktop-database", appDir)
390+ _ = cmd.Run() // Ignore errors, not critical
391+
392+ log.Info("Kiro protocol handler installed for Linux")
393+ return nil
394+}
395+
396+func uninstallLinuxHandler() error {
397+ desktopPath := getLinuxDesktopPath()
398+ scriptPath := getLinuxHandlerScriptPath()
399+
400+ if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
401+ return fmt.Errorf("failed to remove desktop file: %w", err)
402+ }
403+ if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) {
404+ return fmt.Errorf("failed to remove handler script: %w", err)
405+ }
406+
407+ log.Info("Kiro protocol handler uninstalled")
408+ return nil
409+}
410+
411+// --- Windows Implementation ---
412+
413+func isWindowsHandlerInstalled() bool {
414+ // Check registry key existence
415+ cmd := exec.Command("reg", "query", `HKCU\Software\Classes\kiro`, "/ve")
416+ return cmd.Run() == nil
417+}
418+
419+func installWindowsHandler(handlerPort int) error {
420+ homeDir, err := os.UserHomeDir()
421+ if err != nil {
422+ return err
423+ }
424+
425+ // Create handler script (PowerShell)
426+ scriptDir := filepath.Join(homeDir, ".cliproxyapi")
427+ if err := os.MkdirAll(scriptDir, 0755); err != nil {
428+ return fmt.Errorf("failed to create script directory: %w", err)
429+ }
430+
431+ scriptPath := filepath.Join(scriptDir, "kiro-oauth-handler.ps1")
432+ scriptContent := fmt.Sprintf(`# Kiro OAuth Protocol Handler for Windows
433+param([string]$url)
434+
435+# Load required assembly for HttpUtility
436+Add-Type -AssemblyName System.Web
437+
438+# Parse URL parameters
439+$uri = [System.Uri]$url
440+$query = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
441+$code = $query["code"]
442+$state = $query["state"]
443+$errorParam = $query["error"]
444+
445+# Try multiple ports (default + dynamic range)
446+$ports = @(%d, %d, %d, %d, %d)
447+$success = $false
448+
449+foreach ($port in $ports) {
450+ if ($success) { break }
451+ $callbackUrl = "http://127.0.0.1:$port/oauth/callback"
452+ try {
453+ if ($errorParam) {
454+ $fullUrl = $callbackUrl + "?error=" + $errorParam
455+ Invoke-WebRequest -Uri $fullUrl -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null
456+ $success = $true
457+ } elseif ($code -and $state) {
458+ $fullUrl = $callbackUrl + "?code=" + $code + "&state=" + $state
459+ Invoke-WebRequest -Uri $fullUrl -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null
460+ $success = $true
461+ }
462+ } catch {
463+ # Try next port
464+ }
465+}
466+`, handlerPort, handlerPort+1, handlerPort+2, handlerPort+3, handlerPort+4)
467+
468+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
469+ return fmt.Errorf("failed to write handler script: %w", err)
470+ }
471+
472+ // Create batch wrapper
473+ batchPath := filepath.Join(scriptDir, "kiro-oauth-handler.bat")
474+ batchContent := fmt.Sprintf("@echo off\npowershell -ExecutionPolicy Bypass -File \"%s\" %%1\n", scriptPath)
475+
476+ if err := os.WriteFile(batchPath, []byte(batchContent), 0644); err != nil {
477+ return fmt.Errorf("failed to write batch wrapper: %w", err)
478+ }
479+
480+ // Register in Windows registry
481+ commands := [][]string{
482+ {"reg", "add", `HKCU\Software\Classes\kiro`, "/ve", "/d", "URL:Kiro Protocol", "/f"},
483+ {"reg", "add", `HKCU\Software\Classes\kiro`, "/v", "URL Protocol", "/d", "", "/f"},
484+ {"reg", "add", `HKCU\Software\Classes\kiro\shell`, "/f"},
485+ {"reg", "add", `HKCU\Software\Classes\kiro\shell\open`, "/f"},
486+ {"reg", "add", `HKCU\Software\Classes\kiro\shell\open\command`, "/ve", "/d", fmt.Sprintf("\"%s\" \"%%1\"", batchPath), "/f"},
487+ }
488+
489+ for _, args := range commands {
490+ cmd := exec.Command(args[0], args[1:]...)
491+ if err := cmd.Run(); err != nil {
492+ return fmt.Errorf("failed to run registry command: %w", err)
493+ }
494+ }
495+
496+ log.Info("Kiro protocol handler installed for Windows")
497+ return nil
498+}
499+
500+func uninstallWindowsHandler() error {
501+ // Remove registry keys
502+ cmd := exec.Command("reg", "delete", `HKCU\Software\Classes\kiro`, "/f")
503+ if err := cmd.Run(); err != nil {
504+ log.Warnf("failed to remove registry key: %v", err)
505+ }
506+
507+ // Remove scripts
508+ homeDir, _ := os.UserHomeDir()
509+ scriptDir := filepath.Join(homeDir, ".cliproxyapi")
510+ _ = os.Remove(filepath.Join(scriptDir, "kiro-oauth-handler.ps1"))
511+ _ = os.Remove(filepath.Join(scriptDir, "kiro-oauth-handler.bat"))
512+
513+ log.Info("Kiro protocol handler uninstalled")
514+ return nil
515+}
516+
517+// --- macOS Implementation ---
518+
519+func getDarwinAppPath() string {
520+ homeDir, _ := os.UserHomeDir()
521+ return filepath.Join(homeDir, "Applications", "KiroOAuthHandler.app")
522+}
523+
524+func isDarwinHandlerInstalled() bool {
525+ appPath := getDarwinAppPath()
526+ _, err := os.Stat(appPath)
527+ return err == nil
528+}
529+
530+func installDarwinHandler(handlerPort int) error {
531+ // Create app bundle structure
532+ appPath := getDarwinAppPath()
533+ contentsPath := filepath.Join(appPath, "Contents")
534+ macOSPath := filepath.Join(contentsPath, "MacOS")
535+
536+ if err := os.MkdirAll(macOSPath, 0755); err != nil {
537+ return fmt.Errorf("failed to create app bundle: %w", err)
538+ }
539+
540+ // Create Info.plist
541+ plistPath := filepath.Join(contentsPath, "Info.plist")
542+ plistContent := `<?xml version="1.0" encoding="UTF-8"?>
543+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
544+<plist version="1.0">
545+<dict>
546+ <key>CFBundleIdentifier</key>
547+ <string>com.cliproxyapi.kiro-oauth-handler</string>
548+ <key>CFBundleName</key>
549+ <string>KiroOAuthHandler</string>
550+ <key>CFBundleExecutable</key>
551+ <string>kiro-oauth-handler</string>
552+ <key>CFBundleVersion</key>
553+ <string>1.0</string>
554+ <key>CFBundleURLTypes</key>
555+ <array>
556+ <dict>
557+ <key>CFBundleURLName</key>
558+ <string>Kiro Protocol</string>
559+ <key>CFBundleURLSchemes</key>
560+ <array>
561+ <string>kiro</string>
562+ </array>
563+ </dict>
564+ </array>
565+ <key>LSBackgroundOnly</key>
566+ <true/>
567+</dict>
568+</plist>`
569+
570+ if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil {
571+ return fmt.Errorf("failed to write Info.plist: %w", err)
572+ }
573+
574+ // Create executable script - tries multiple ports to handle dynamic port allocation
575+ execPath := filepath.Join(macOSPath, "kiro-oauth-handler")
576+ execContent := fmt.Sprintf(`#!/bin/bash
577+# Kiro OAuth Protocol Handler for macOS
578+
579+URL="$1"
580+
581+# Check curl availability (should always exist on macOS)
582+if [ ! -x /usr/bin/curl ]; then
583+ echo "Error: curl is required for Kiro OAuth handler" >&2
584+ exit 1
585+fi
586+
587+# Extract code and state from URL
588+[[ "$URL" =~ code=([^&]+) ]] && CODE="${BASH_REMATCH[1]}"
589+[[ "$URL" =~ state=([^&]+) ]] && STATE="${BASH_REMATCH[1]}"
590+[[ "$URL" =~ error=([^&]+) ]] && ERROR="${BASH_REMATCH[1]}"
591+
592+# Try multiple ports (default + dynamic range)
593+for PORT in %d %d %d %d %d; do
594+ if [ -n "$ERROR" ]; then
595+ /usr/bin/curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?error=$ERROR" && exit 0
596+ elif [ -n "$CODE" ] && [ -n "$STATE" ]; then
597+ /usr/bin/curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?code=$CODE&state=$STATE" && exit 0
598+ fi
599+done
600+`, handlerPort, handlerPort+1, handlerPort+2, handlerPort+3, handlerPort+4)
601+
602+ if err := os.WriteFile(execPath, []byte(execContent), 0755); err != nil {
603+ return fmt.Errorf("failed to write executable: %w", err)
604+ }
605+
606+ // Register the app with Launch Services
607+ cmd := exec.Command("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
608+ "-f", appPath)
609+ if err := cmd.Run(); err != nil {
610+ log.Warnf("lsregister failed (handler may still work): %v", err)
611+ }
612+
613+ log.Info("Kiro protocol handler installed for macOS")
614+ return nil
615+}
616+
617+func uninstallDarwinHandler() error {
618+ appPath := getDarwinAppPath()
619+
620+ // Unregister from Launch Services
621+ cmd := exec.Command("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
622+ "-u", appPath)
623+ _ = cmd.Run()
624+
625+ // Remove app bundle
626+ if err := os.RemoveAll(appPath); err != nil && !os.IsNotExist(err) {
627+ return fmt.Errorf("failed to remove app bundle: %w", err)
628+ }
629+
630+ log.Info("Kiro protocol handler uninstalled")
631+ return nil
632+}
633+
634+// ParseKiroURI parses a kiro:// URI and extracts the callback parameters.
635+func ParseKiroURI(rawURI string) (*AuthCallback, error) {
636+ u, err := url.Parse(rawURI)
637+ if err != nil {
638+ return nil, fmt.Errorf("invalid URI: %w", err)
639+ }
640+
641+ if u.Scheme != KiroProtocol {
642+ return nil, fmt.Errorf("invalid scheme: expected %s, got %s", KiroProtocol, u.Scheme)
643+ }
644+
645+ if u.Host != KiroAuthority {
646+ return nil, fmt.Errorf("invalid authority: expected %s, got %s", KiroAuthority, u.Host)
647+ }
648+
649+ query := u.Query()
650+ return &AuthCallback{
651+ Code: query.Get("code"),
652+ State: query.Get("state"),
653+ Error: query.Get("error"),
654+ }, nil
655+}
656+
657+// GetHandlerInstructions returns platform-specific instructions for manual handler setup.
658+func GetHandlerInstructions() string {
659+ switch runtime.GOOS {
660+ case "linux":
661+ return `To manually set up the Kiro protocol handler on Linux:
662+
663+1. Create ~/.local/share/applications/kiro-oauth-handler.desktop:
664+ [Desktop Entry]
665+ Name=Kiro OAuth Handler
666+ Exec=~/.local/bin/kiro-oauth-handler %u
667+ Type=Application
668+ Terminal=false
669+ MimeType=x-scheme-handler/kiro;
670+
671+2. Create ~/.local/bin/kiro-oauth-handler (make it executable):
672+ #!/bin/bash
673+ URL="$1"
674+ # ... (see generated script for full content)
675+
676+3. Run: xdg-mime default kiro-oauth-handler.desktop x-scheme-handler/kiro`
677+
678+ case "windows":
679+ return `To manually set up the Kiro protocol handler on Windows:
680+
681+1. Open Registry Editor (regedit.exe)
682+2. Create key: HKEY_CURRENT_USER\Software\Classes\kiro
683+3. Set default value to: URL:Kiro Protocol
684+4. Create string value "URL Protocol" with empty data
685+5. Create subkey: shell\open\command
686+6. Set default value to: "C:\path\to\handler.bat" "%1"`
687+
688+ case "darwin":
689+ return `To manually set up the Kiro protocol handler on macOS:
690+
691+1. Create ~/Applications/KiroOAuthHandler.app bundle
692+2. Add Info.plist with CFBundleURLTypes containing "kiro" scheme
693+3. Create executable in Contents/MacOS/
694+4. Run: /System/Library/.../lsregister -f ~/Applications/KiroOAuthHandler.app`
695+
696+ default:
697+ return "Protocol handler setup is not supported on this platform."
698+ }
699+}
700+
701+// SetupProtocolHandlerIfNeeded checks and installs the protocol handler if needed.
702+func SetupProtocolHandlerIfNeeded(handlerPort int) error {
703+ if IsProtocolHandlerInstalled() {
704+ log.Debug("Kiro protocol handler already installed")
705+ return nil
706+ }
707+
708+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
709+ fmt.Println("║ Kiro Protocol Handler Setup Required ║")
710+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
711+ fmt.Println("\nTo enable Google/GitHub login, we need to install a protocol handler.")
712+ fmt.Println("This allows your browser to redirect back to the CLI after authentication.")
713+ fmt.Println("\nInstalling protocol handler...")
714+
715+ if err := InstallProtocolHandler(handlerPort); err != nil {
716+ fmt.Printf("\n⚠ Automatic installation failed: %v\n", err)
717+ fmt.Println("\nManual setup instructions:")
718+ fmt.Println(strings.Repeat("-", 60))
719+ fmt.Println(GetHandlerInstructions())
720+ return err
721+ }
722+
723+ fmt.Println("\n✓ Protocol handler installed successfully!")
724+ return nil
725+}
internal/auth/kiro/social_auth.go+403−0
@@ -0,0 +1,403 @@
1+// Package kiro provides social authentication (Google/GitHub) for Kiro via AuthServiceClient.
2+package kiro
3+
4+import (
5+ "bufio"
6+ "context"
7+ "crypto/rand"
8+ "crypto/sha256"
9+ "encoding/base64"
10+ "encoding/json"
11+ "fmt"
12+ "io"
13+ "net/http"
14+ "net/url"
15+ "os"
16+ "os/exec"
17+ "runtime"
18+ "strings"
19+ "time"
20+
21+ "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
22+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
23+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
24+ log "github.com/sirupsen/logrus"
25+ "golang.org/x/term"
26+)
27+
28+const (
29+ // Kiro AuthService endpoint
30+ kiroAuthServiceEndpoint = "https://prod.us-east-1.auth.desktop.kiro.dev"
31+
32+ // OAuth timeout
33+ socialAuthTimeout = 10 * time.Minute
34+)
35+
36+// SocialProvider represents the social login provider.
37+type SocialProvider string
38+
39+const (
40+ // ProviderGoogle is Google OAuth provider
41+ ProviderGoogle SocialProvider = "Google"
42+ // ProviderGitHub is GitHub OAuth provider
43+ ProviderGitHub SocialProvider = "Github"
44+ // Note: AWS Builder ID is NOT supported by Kiro's auth service.
45+ // It only supports: Google, Github, Cognito
46+ // AWS Builder ID must use device code flow via SSO OIDC.
47+)
48+
49+// CreateTokenRequest is sent to Kiro's /oauth/token endpoint.
50+type CreateTokenRequest struct {
51+ Code string `json:"code"`
52+ CodeVerifier string `json:"code_verifier"`
53+ RedirectURI string `json:"redirect_uri"`
54+ InvitationCode string `json:"invitation_code,omitempty"`
55+}
56+
57+// SocialTokenResponse from Kiro's /oauth/token endpoint for social auth.
58+type SocialTokenResponse struct {
59+ AccessToken string `json:"accessToken"`
60+ RefreshToken string `json:"refreshToken"`
61+ ProfileArn string `json:"profileArn"`
62+ ExpiresIn int `json:"expiresIn"`
63+}
64+
65+// RefreshTokenRequest is sent to Kiro's /refreshToken endpoint.
66+type RefreshTokenRequest struct {
67+ RefreshToken string `json:"refreshToken"`
68+}
69+
70+// SocialAuthClient handles social authentication with Kiro.
71+type SocialAuthClient struct {
72+ httpClient *http.Client
73+ cfg *config.Config
74+ protocolHandler *ProtocolHandler
75+}
76+
77+// NewSocialAuthClient creates a new social auth client.
78+func NewSocialAuthClient(cfg *config.Config) *SocialAuthClient {
79+ client := &http.Client{Timeout: 30 * time.Second}
80+ if cfg != nil {
81+ client = util.SetProxy(&cfg.SDKConfig, client)
82+ }
83+ return &SocialAuthClient{
84+ httpClient: client,
85+ cfg: cfg,
86+ protocolHandler: NewProtocolHandler(),
87+ }
88+}
89+
90+// generatePKCE generates PKCE code verifier and challenge.
91+func generatePKCE() (verifier, challenge string, err error) {
92+ // Generate 32 bytes of random data for verifier
93+ b := make([]byte, 32)
94+ if _, err := rand.Read(b); err != nil {
95+ return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
96+ }
97+ verifier = base64.RawURLEncoding.EncodeToString(b)
98+
99+ // Generate SHA256 hash of verifier for challenge
100+ h := sha256.Sum256([]byte(verifier))
101+ challenge = base64.RawURLEncoding.EncodeToString(h[:])
102+
103+ return verifier, challenge, nil
104+}
105+
106+// generateState generates a random state parameter.
107+func generateStateParam() (string, error) {
108+ b := make([]byte, 16)
109+ if _, err := rand.Read(b); err != nil {
110+ return "", err
111+ }
112+ return base64.RawURLEncoding.EncodeToString(b), nil
113+}
114+
115+// buildLoginURL constructs the Kiro OAuth login URL.
116+// The login endpoint expects a GET request with query parameters.
117+// Format: /login?idp=Google&redirect_uri=...&code_challenge=...&code_challenge_method=S256&state=...&prompt=select_account
118+// The prompt=select_account parameter forces the account selection screen even if already logged in.
119+func (c *SocialAuthClient) buildLoginURL(provider, redirectURI, codeChallenge, state string) string {
120+ return fmt.Sprintf("%s/login?idp=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&state=%s&prompt=select_account",
121+ kiroAuthServiceEndpoint,
122+ provider,
123+ url.QueryEscape(redirectURI),
124+ codeChallenge,
125+ state,
126+ )
127+}
128+
129+// CreateToken exchanges the authorization code for tokens.
130+func (c *SocialAuthClient) CreateToken(ctx context.Context, req *CreateTokenRequest) (*SocialTokenResponse, error) {
131+ body, err := json.Marshal(req)
132+ if err != nil {
133+ return nil, fmt.Errorf("failed to marshal token request: %w", err)
134+ }
135+
136+ tokenURL := kiroAuthServiceEndpoint + "/oauth/token"
137+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(string(body)))
138+ if err != nil {
139+ return nil, fmt.Errorf("failed to create token request: %w", err)
140+ }
141+
142+ httpReq.Header.Set("Content-Type", "application/json")
143+ httpReq.Header.Set("User-Agent", "cli-proxy-api/1.0.0")
144+
145+ resp, err := c.httpClient.Do(httpReq)
146+ if err != nil {
147+ return nil, fmt.Errorf("token request failed: %w", err)
148+ }
149+ defer resp.Body.Close()
150+
151+ respBody, err := io.ReadAll(resp.Body)
152+ if err != nil {
153+ return nil, fmt.Errorf("failed to read token response: %w", err)
154+ }
155+
156+ if resp.StatusCode != http.StatusOK {
157+ log.Debugf("token exchange failed (status %d): %s", resp.StatusCode, string(respBody))
158+ return nil, fmt.Errorf("token exchange failed (status %d)", resp.StatusCode)
159+ }
160+
161+ var tokenResp SocialTokenResponse
162+ if err := json.Unmarshal(respBody, &tokenResp); err != nil {
163+ return nil, fmt.Errorf("failed to parse token response: %w", err)
164+ }
165+
166+ return &tokenResp, nil
167+}
168+
169+// RefreshSocialToken refreshes an expired social auth token.
170+func (c *SocialAuthClient) RefreshSocialToken(ctx context.Context, refreshToken string) (*KiroTokenData, error) {
171+ body, err := json.Marshal(&RefreshTokenRequest{RefreshToken: refreshToken})
172+ if err != nil {
173+ return nil, fmt.Errorf("failed to marshal refresh request: %w", err)
174+ }
175+
176+ refreshURL := kiroAuthServiceEndpoint + "/refreshToken"
177+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, refreshURL, strings.NewReader(string(body)))
178+ if err != nil {
179+ return nil, fmt.Errorf("failed to create refresh request: %w", err)
180+ }
181+
182+ httpReq.Header.Set("Content-Type", "application/json")
183+ httpReq.Header.Set("User-Agent", "cli-proxy-api/1.0.0")
184+
185+ resp, err := c.httpClient.Do(httpReq)
186+ if err != nil {
187+ return nil, fmt.Errorf("refresh request failed: %w", err)
188+ }
189+ defer resp.Body.Close()
190+
191+ respBody, err := io.ReadAll(resp.Body)
192+ if err != nil {
193+ return nil, fmt.Errorf("failed to read refresh response: %w", err)
194+ }
195+
196+ if resp.StatusCode != http.StatusOK {
197+ log.Debugf("token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
198+ return nil, fmt.Errorf("token refresh failed (status %d)", resp.StatusCode)
199+ }
200+
201+ var tokenResp SocialTokenResponse
202+ if err := json.Unmarshal(respBody, &tokenResp); err != nil {
203+ return nil, fmt.Errorf("failed to parse refresh response: %w", err)
204+ }
205+
206+ // Validate ExpiresIn - use default 1 hour if invalid
207+ expiresIn := tokenResp.ExpiresIn
208+ if expiresIn <= 0 {
209+ expiresIn = 3600 // Default 1 hour
210+ }
211+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
212+
213+ return &KiroTokenData{
214+ AccessToken: tokenResp.AccessToken,
215+ RefreshToken: tokenResp.RefreshToken,
216+ ProfileArn: tokenResp.ProfileArn,
217+ ExpiresAt: expiresAt.Format(time.RFC3339),
218+ AuthMethod: "social",
219+ Provider: "", // Caller should preserve original provider
220+ }, nil
221+}
222+
223+// LoginWithSocial performs OAuth login with Google.
224+func (c *SocialAuthClient) LoginWithSocial(ctx context.Context, provider SocialProvider) (*KiroTokenData, error) {
225+ providerName := string(provider)
226+
227+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
228+ fmt.Printf("║ Kiro Authentication (%s) ║\n", providerName)
229+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
230+
231+ // Step 1: Setup protocol handler
232+ fmt.Println("\nSetting up authentication...")
233+
234+ // Start the local callback server
235+ handlerPort, err := c.protocolHandler.Start(ctx)
236+ if err != nil {
237+ return nil, fmt.Errorf("failed to start callback server: %w", err)
238+ }
239+ defer c.protocolHandler.Stop()
240+
241+ // Ensure protocol handler is installed and set as default
242+ if err := SetupProtocolHandlerIfNeeded(handlerPort); err != nil {
243+ fmt.Println("\n⚠ Protocol handler setup failed. Trying alternative method...")
244+ fmt.Println(" If you see a browser 'Open with' dialog, select your default browser.")
245+ fmt.Println(" For manual setup instructions, run: cliproxy kiro --help-protocol")
246+ log.Debugf("kiro: protocol handler setup error: %v", err)
247+ // Continue anyway - user might have set it up manually or select browser manually
248+ } else {
249+ // Force set our handler as default (prevents "Open with" dialog)
250+ forceDefaultProtocolHandler()
251+ }
252+
253+ // Step 2: Generate PKCE codes
254+ codeVerifier, codeChallenge, err := generatePKCE()
255+ if err != nil {
256+ return nil, fmt.Errorf("failed to generate PKCE: %w", err)
257+ }
258+
259+ // Step 3: Generate state
260+ state, err := generateStateParam()
261+ if err != nil {
262+ return nil, fmt.Errorf("failed to generate state: %w", err)
263+ }
264+
265+ // Step 4: Build the login URL (Kiro uses GET request with query params)
266+ authURL := c.buildLoginURL(providerName, KiroRedirectURI, codeChallenge, state)
267+
268+ // Set incognito mode based on config (defaults to true for Kiro, can be overridden with --no-incognito)
269+ // Incognito mode enables multi-account support by bypassing cached sessions
270+ if c.cfg != nil {
271+ browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
272+ if !c.cfg.IncognitoBrowser {
273+ log.Info("kiro: using normal browser mode (--no-incognito). Note: You may not be able to select a different account.")
274+ } else {
275+ log.Debug("kiro: using incognito mode for multi-account support")
276+ }
277+ } else {
278+ browser.SetIncognitoMode(true) // Default to incognito if no config
279+ log.Debug("kiro: using incognito mode for multi-account support (default)")
280+ }
281+
282+ // Step 5: Open browser for user authentication
283+ fmt.Println("\n════════════════════════════════════════════════════════════")
284+ fmt.Printf(" Opening browser for %s authentication...\n", providerName)
285+ fmt.Println("════════════════════════════════════════════════════════════")
286+ fmt.Printf("\n URL: %s\n\n", authURL)
287+
288+ if err := browser.OpenURL(authURL); err != nil {
289+ log.Warnf("Could not open browser automatically: %v", err)
290+ fmt.Println(" ⚠ Could not open browser automatically.")
291+ fmt.Println(" Please open the URL above in your browser manually.")
292+ } else {
293+ fmt.Println(" (Browser opened automatically)")
294+ }
295+
296+ fmt.Println("\n Waiting for authentication callback...")
297+
298+ // Step 6: Wait for callback
299+ callback, err := c.protocolHandler.WaitForCallback(ctx)
300+ if err != nil {
301+ return nil, fmt.Errorf("failed to receive callback: %w", err)
302+ }
303+
304+ if callback.Error != "" {
305+ return nil, fmt.Errorf("authentication error: %s", callback.Error)
306+ }
307+
308+ if callback.State != state {
309+ // Log state values for debugging, but don't expose in user-facing error
310+ log.Debugf("kiro: OAuth state mismatch - expected %s, got %s", state, callback.State)
311+ return nil, fmt.Errorf("OAuth state validation failed - please try again")
312+ }
313+
314+ if callback.Code == "" {
315+ return nil, fmt.Errorf("no authorization code received")
316+ }
317+
318+ fmt.Println("\n✓ Authorization received!")
319+
320+ // Step 7: Exchange code for tokens
321+ fmt.Println("Exchanging code for tokens...")
322+
323+ tokenReq := &CreateTokenRequest{
324+ Code: callback.Code,
325+ CodeVerifier: codeVerifier,
326+ RedirectURI: KiroRedirectURI,
327+ }
328+
329+ tokenResp, err := c.CreateToken(ctx, tokenReq)
330+ if err != nil {
331+ return nil, fmt.Errorf("failed to exchange code for tokens: %w", err)
332+ }
333+
334+ fmt.Println("\n✓ Authentication successful!")
335+
336+ // Close the browser window
337+ if err := browser.CloseBrowser(); err != nil {
338+ log.Debugf("Failed to close browser: %v", err)
339+ }
340+
341+ // Validate ExpiresIn - use default 1 hour if invalid
342+ expiresIn := tokenResp.ExpiresIn
343+ if expiresIn <= 0 {
344+ expiresIn = 3600
345+ }
346+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
347+
348+ // Try to extract email from JWT access token first
349+ email := ExtractEmailFromJWT(tokenResp.AccessToken)
350+
351+ // If no email in JWT, ask user for account label (only in interactive mode)
352+ if email == "" && isInteractiveTerminal() {
353+ fmt.Print("\n Enter account label for file naming (optional, press Enter to skip): ")
354+ reader := bufio.NewReader(os.Stdin)
355+ var err error
356+ email, err = reader.ReadString('\n')
357+ if err != nil {
358+ log.Debugf("Failed to read account label: %v", err)
359+ }
360+ email = strings.TrimSpace(email)
361+ }
362+
363+ return &KiroTokenData{
364+ AccessToken: tokenResp.AccessToken,
365+ RefreshToken: tokenResp.RefreshToken,
366+ ProfileArn: tokenResp.ProfileArn,
367+ ExpiresAt: expiresAt.Format(time.RFC3339),
368+ AuthMethod: "social",
369+ Provider: providerName,
370+ Email: email, // JWT email or user-provided label
371+ }, nil
372+}
373+
374+// LoginWithGoogle performs OAuth login with Google.
375+func (c *SocialAuthClient) LoginWithGoogle(ctx context.Context) (*KiroTokenData, error) {
376+ return c.LoginWithSocial(ctx, ProviderGoogle)
377+}
378+
379+// LoginWithGitHub performs OAuth login with GitHub.
380+func (c *SocialAuthClient) LoginWithGitHub(ctx context.Context) (*KiroTokenData, error) {
381+ return c.LoginWithSocial(ctx, ProviderGitHub)
382+}
383+
384+// forceDefaultProtocolHandler sets our protocol handler as the default for kiro:// URLs.
385+// This prevents the "Open with" dialog from appearing on Linux.
386+// On non-Linux platforms, this is a no-op as they use different mechanisms.
387+func forceDefaultProtocolHandler() {
388+ if runtime.GOOS != "linux" {
389+ return // Non-Linux platforms use different handler mechanisms
390+ }
391+
392+ // Set our handler as default using xdg-mime
393+ cmd := exec.Command("xdg-mime", "default", "kiro-oauth-handler.desktop", "x-scheme-handler/kiro")
394+ if err := cmd.Run(); err != nil {
395+ log.Warnf("Failed to set default protocol handler: %v. You may see a handler selection dialog.", err)
396+ }
397+}
398+
399+// isInteractiveTerminal checks if stdin is connected to an interactive terminal.
400+// Returns false in CI/automated environments or when stdin is piped.
401+func isInteractiveTerminal() bool {
402+ return term.IsTerminal(int(os.Stdin.Fd()))
403+}
internal/auth/kiro/sso_oidc.go+1371−0
@@ -0,0 +1,1371 @@
1+// Package kiro provides AWS SSO OIDC authentication for Kiro.
2+package kiro
3+
4+import (
5+ "bufio"
6+ "context"
7+ "crypto/rand"
8+ "crypto/sha256"
9+ "encoding/base64"
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "html"
14+ "io"
15+ "net"
16+ "net/http"
17+ "os"
18+ "strings"
19+ "time"
20+
21+ "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
22+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
23+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
24+ log "github.com/sirupsen/logrus"
25+)
26+
27+const (
28+ // AWS SSO OIDC endpoints
29+ ssoOIDCEndpoint = "https://oidc.us-east-1.amazonaws.com"
30+
31+ // Kiro's start URL for Builder ID
32+ builderIDStartURL = "https://view.awsapps.com/start"
33+
34+ // Default region for IDC
35+ defaultIDCRegion = "us-east-1"
36+
37+ // Polling interval
38+ pollInterval = 5 * time.Second
39+
40+ // Authorization code flow callback
41+ authCodeCallbackPath = "/oauth/callback"
42+ authCodeCallbackPort = 19877
43+
44+ // User-Agent to match official Kiro IDE
45+ kiroUserAgent = "KiroIDE"
46+
47+ // IDC token refresh headers (matching Kiro IDE behavior)
48+ idcAmzUserAgent = "aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE"
49+)
50+
51+// Sentinel errors for OIDC token polling
52+var (
53+ ErrAuthorizationPending = errors.New("authorization_pending")
54+ ErrSlowDown = errors.New("slow_down")
55+)
56+
57+// SSOOIDCClient handles AWS SSO OIDC authentication.
58+type SSOOIDCClient struct {
59+ httpClient *http.Client
60+ cfg *config.Config
61+}
62+
63+// NewSSOOIDCClient creates a new SSO OIDC client.
64+func NewSSOOIDCClient(cfg *config.Config) *SSOOIDCClient {
65+ client := &http.Client{Timeout: 30 * time.Second}
66+ if cfg != nil {
67+ client = util.SetProxy(&cfg.SDKConfig, client)
68+ }
69+ return &SSOOIDCClient{
70+ httpClient: client,
71+ cfg: cfg,
72+ }
73+}
74+
75+// RegisterClientResponse from AWS SSO OIDC.
76+type RegisterClientResponse struct {
77+ ClientID string `json:"clientId"`
78+ ClientSecret string `json:"clientSecret"`
79+ ClientIDIssuedAt int64 `json:"clientIdIssuedAt"`
80+ ClientSecretExpiresAt int64 `json:"clientSecretExpiresAt"`
81+}
82+
83+// StartDeviceAuthResponse from AWS SSO OIDC.
84+type StartDeviceAuthResponse struct {
85+ DeviceCode string `json:"deviceCode"`
86+ UserCode string `json:"userCode"`
87+ VerificationURI string `json:"verificationUri"`
88+ VerificationURIComplete string `json:"verificationUriComplete"`
89+ ExpiresIn int `json:"expiresIn"`
90+ Interval int `json:"interval"`
91+}
92+
93+// CreateTokenResponse from AWS SSO OIDC.
94+type CreateTokenResponse struct {
95+ AccessToken string `json:"accessToken"`
96+ TokenType string `json:"tokenType"`
97+ ExpiresIn int `json:"expiresIn"`
98+ RefreshToken string `json:"refreshToken"`
99+}
100+
101+// getOIDCEndpoint returns the OIDC endpoint for the given region.
102+func getOIDCEndpoint(region string) string {
103+ if region == "" {
104+ region = defaultIDCRegion
105+ }
106+ return fmt.Sprintf("https://oidc.%s.amazonaws.com", region)
107+}
108+
109+// promptInput prompts the user for input with an optional default value.
110+func promptInput(prompt, defaultValue string) string {
111+ reader := bufio.NewReader(os.Stdin)
112+ if defaultValue != "" {
113+ fmt.Printf("%s [%s]: ", prompt, defaultValue)
114+ } else {
115+ fmt.Printf("%s: ", prompt)
116+ }
117+ input, err := reader.ReadString('\n')
118+ if err != nil {
119+ log.Warnf("Error reading input: %v", err)
120+ return defaultValue
121+ }
122+ input = strings.TrimSpace(input)
123+ if input == "" {
124+ return defaultValue
125+ }
126+ return input
127+}
128+
129+// promptSelect prompts the user to select from options using number input.
130+func promptSelect(prompt string, options []string) int {
131+ reader := bufio.NewReader(os.Stdin)
132+
133+ for {
134+ fmt.Println(prompt)
135+ for i, opt := range options {
136+ fmt.Printf(" %d) %s\n", i+1, opt)
137+ }
138+ fmt.Printf("Enter selection (1-%d): ", len(options))
139+
140+ input, err := reader.ReadString('\n')
141+ if err != nil {
142+ log.Warnf("Error reading input: %v", err)
143+ return 0 // Default to first option on error
144+ }
145+ input = strings.TrimSpace(input)
146+
147+ // Parse the selection
148+ var selection int
149+ if _, err := fmt.Sscanf(input, "%d", &selection); err != nil || selection < 1 || selection > len(options) {
150+ fmt.Printf("Invalid selection '%s'. Please enter a number between 1 and %d.\n\n", input, len(options))
151+ continue
152+ }
153+ return selection - 1
154+ }
155+}
156+
157+// RegisterClientWithRegion registers a new OIDC client with AWS using a specific region.
158+func (c *SSOOIDCClient) RegisterClientWithRegion(ctx context.Context, region string) (*RegisterClientResponse, error) {
159+ endpoint := getOIDCEndpoint(region)
160+
161+ payload := map[string]interface{}{
162+ "clientName": "Kiro IDE",
163+ "clientType": "public",
164+ "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"},
165+ "grantTypes": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
166+ }
167+
168+ body, err := json.Marshal(payload)
169+ if err != nil {
170+ return nil, err
171+ }
172+
173+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/client/register", strings.NewReader(string(body)))
174+ if err != nil {
175+ return nil, err
176+ }
177+ req.Header.Set("Content-Type", "application/json")
178+ req.Header.Set("User-Agent", kiroUserAgent)
179+
180+ resp, err := c.httpClient.Do(req)
181+ if err != nil {
182+ return nil, err
183+ }
184+ defer resp.Body.Close()
185+
186+ respBody, err := io.ReadAll(resp.Body)
187+ if err != nil {
188+ return nil, err
189+ }
190+
191+ if resp.StatusCode != http.StatusOK {
192+ log.Debugf("register client failed (status %d): %s", resp.StatusCode, string(respBody))
193+ return nil, fmt.Errorf("register client failed (status %d)", resp.StatusCode)
194+ }
195+
196+ var result RegisterClientResponse
197+ if err := json.Unmarshal(respBody, &result); err != nil {
198+ return nil, err
199+ }
200+
201+ return &result, nil
202+}
203+
204+// StartDeviceAuthorizationWithIDC starts the device authorization flow for IDC.
205+func (c *SSOOIDCClient) StartDeviceAuthorizationWithIDC(ctx context.Context, clientID, clientSecret, startURL, region string) (*StartDeviceAuthResponse, error) {
206+ endpoint := getOIDCEndpoint(region)
207+
208+ payload := map[string]string{
209+ "clientId": clientID,
210+ "clientSecret": clientSecret,
211+ "startUrl": startURL,
212+ }
213+
214+ body, err := json.Marshal(payload)
215+ if err != nil {
216+ return nil, err
217+ }
218+
219+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/device_authorization", strings.NewReader(string(body)))
220+ if err != nil {
221+ return nil, err
222+ }
223+ req.Header.Set("Content-Type", "application/json")
224+ req.Header.Set("User-Agent", kiroUserAgent)
225+
226+ resp, err := c.httpClient.Do(req)
227+ if err != nil {
228+ return nil, err
229+ }
230+ defer resp.Body.Close()
231+
232+ respBody, err := io.ReadAll(resp.Body)
233+ if err != nil {
234+ return nil, err
235+ }
236+
237+ if resp.StatusCode != http.StatusOK {
238+ log.Debugf("start device auth failed (status %d): %s", resp.StatusCode, string(respBody))
239+ return nil, fmt.Errorf("start device auth failed (status %d)", resp.StatusCode)
240+ }
241+
242+ var result StartDeviceAuthResponse
243+ if err := json.Unmarshal(respBody, &result); err != nil {
244+ return nil, err
245+ }
246+
247+ return &result, nil
248+}
249+
250+// CreateTokenWithRegion polls for the access token after user authorization using a specific region.
251+func (c *SSOOIDCClient) CreateTokenWithRegion(ctx context.Context, clientID, clientSecret, deviceCode, region string) (*CreateTokenResponse, error) {
252+ endpoint := getOIDCEndpoint(region)
253+
254+ payload := map[string]string{
255+ "clientId": clientID,
256+ "clientSecret": clientSecret,
257+ "deviceCode": deviceCode,
258+ "grantType": "urn:ietf:params:oauth:grant-type:device_code",
259+ }
260+
261+ body, err := json.Marshal(payload)
262+ if err != nil {
263+ return nil, err
264+ }
265+
266+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/token", strings.NewReader(string(body)))
267+ if err != nil {
268+ return nil, err
269+ }
270+ req.Header.Set("Content-Type", "application/json")
271+ req.Header.Set("User-Agent", kiroUserAgent)
272+
273+ resp, err := c.httpClient.Do(req)
274+ if err != nil {
275+ return nil, err
276+ }
277+ defer resp.Body.Close()
278+
279+ respBody, err := io.ReadAll(resp.Body)
280+ if err != nil {
281+ return nil, err
282+ }
283+
284+ // Check for pending authorization
285+ if resp.StatusCode == http.StatusBadRequest {
286+ var errResp struct {
287+ Error string `json:"error"`
288+ }
289+ if json.Unmarshal(respBody, &errResp) == nil {
290+ if errResp.Error == "authorization_pending" {
291+ return nil, ErrAuthorizationPending
292+ }
293+ if errResp.Error == "slow_down" {
294+ return nil, ErrSlowDown
295+ }
296+ }
297+ log.Debugf("create token failed: %s", string(respBody))
298+ return nil, fmt.Errorf("create token failed")
299+ }
300+
301+ if resp.StatusCode != http.StatusOK {
302+ log.Debugf("create token failed (status %d): %s", resp.StatusCode, string(respBody))
303+ return nil, fmt.Errorf("create token failed (status %d)", resp.StatusCode)
304+ }
305+
306+ var result CreateTokenResponse
307+ if err := json.Unmarshal(respBody, &result); err != nil {
308+ return nil, err
309+ }
310+
311+ return &result, nil
312+}
313+
314+// RefreshTokenWithRegion refreshes an access token using the refresh token with a specific region.
315+func (c *SSOOIDCClient) RefreshTokenWithRegion(ctx context.Context, clientID, clientSecret, refreshToken, region, startURL string) (*KiroTokenData, error) {
316+ endpoint := getOIDCEndpoint(region)
317+
318+ payload := map[string]string{
319+ "clientId": clientID,
320+ "clientSecret": clientSecret,
321+ "refreshToken": refreshToken,
322+ "grantType": "refresh_token",
323+ }
324+
325+ body, err := json.Marshal(payload)
326+ if err != nil {
327+ return nil, err
328+ }
329+
330+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/token", strings.NewReader(string(body)))
331+ if err != nil {
332+ return nil, err
333+ }
334+
335+ // Set headers matching kiro2api's IDC token refresh
336+ // These headers are required for successful IDC token refresh
337+ req.Header.Set("Content-Type", "application/json")
338+ req.Header.Set("Host", fmt.Sprintf("oidc.%s.amazonaws.com", region))
339+ req.Header.Set("Connection", "keep-alive")
340+ req.Header.Set("x-amz-user-agent", idcAmzUserAgent)
341+ req.Header.Set("Accept", "*/*")
342+ req.Header.Set("Accept-Language", "*")
343+ req.Header.Set("sec-fetch-mode", "cors")
344+ req.Header.Set("User-Agent", "node")
345+ req.Header.Set("Accept-Encoding", "br, gzip, deflate")
346+
347+ resp, err := c.httpClient.Do(req)
348+ if err != nil {
349+ return nil, err
350+ }
351+ defer resp.Body.Close()
352+
353+ respBody, err := io.ReadAll(resp.Body)
354+ if err != nil {
355+ return nil, err
356+ }
357+
358+ if resp.StatusCode != http.StatusOK {
359+ log.Warnf("IDC token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
360+ return nil, fmt.Errorf("token refresh failed (status %d)", resp.StatusCode)
361+ }
362+
363+ var result CreateTokenResponse
364+ if err := json.Unmarshal(respBody, &result); err != nil {
365+ return nil, err
366+ }
367+
368+ expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
369+
370+ return &KiroTokenData{
371+ AccessToken: result.AccessToken,
372+ RefreshToken: result.RefreshToken,
373+ ExpiresAt: expiresAt.Format(time.RFC3339),
374+ AuthMethod: "idc",
375+ Provider: "AWS",
376+ ClientID: clientID,
377+ ClientSecret: clientSecret,
378+ StartURL: startURL,
379+ Region: region,
380+ }, nil
381+}
382+
383+// LoginWithIDC performs the full device code flow for AWS Identity Center (IDC).
384+func (c *SSOOIDCClient) LoginWithIDC(ctx context.Context, startURL, region string) (*KiroTokenData, error) {
385+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
386+ fmt.Println("║ Kiro Authentication (AWS Identity Center) ║")
387+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
388+
389+ // Step 1: Register client with the specified region
390+ fmt.Println("\nRegistering client...")
391+ regResp, err := c.RegisterClientWithRegion(ctx, region)
392+ if err != nil {
393+ return nil, fmt.Errorf("failed to register client: %w", err)
394+ }
395+ log.Debugf("Client registered: %s", regResp.ClientID)
396+
397+ // Step 2: Start device authorization with IDC start URL
398+ fmt.Println("Starting device authorization...")
399+ authResp, err := c.StartDeviceAuthorizationWithIDC(ctx, regResp.ClientID, regResp.ClientSecret, startURL, region)
400+ if err != nil {
401+ return nil, fmt.Errorf("failed to start device auth: %w", err)
402+ }
403+
404+ // Step 3: Show user the verification URL
405+ fmt.Printf("\n")
406+ fmt.Println("════════════════════════════════════════════════════════════")
407+ fmt.Printf(" Confirm the following code in the browser:\n")
408+ fmt.Printf(" Code: %s\n", authResp.UserCode)
409+ fmt.Println("════════════════════════════════════════════════════════════")
410+ fmt.Printf("\n Open this URL: %s\n\n", authResp.VerificationURIComplete)
411+
412+ // Set incognito mode based on config
413+ if c.cfg != nil {
414+ browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
415+ if !c.cfg.IncognitoBrowser {
416+ log.Info("kiro: using normal browser mode (--no-incognito). Note: You may not be able to select a different account.")
417+ } else {
418+ log.Debug("kiro: using incognito mode for multi-account support")
419+ }
420+ } else {
421+ browser.SetIncognitoMode(true)
422+ log.Debug("kiro: using incognito mode for multi-account support (default)")
423+ }
424+
425+ // Open browser
426+ if err := browser.OpenURL(authResp.VerificationURIComplete); err != nil {
427+ log.Warnf("Could not open browser automatically: %v", err)
428+ fmt.Println(" Please open the URL manually in your browser.")
429+ } else {
430+ fmt.Println(" (Browser opened automatically)")
431+ }
432+
433+ // Step 4: Poll for token
434+ fmt.Println("Waiting for authorization...")
435+
436+ interval := pollInterval
437+ if authResp.Interval > 0 {
438+ interval = time.Duration(authResp.Interval) * time.Second
439+ }
440+
441+ deadline := time.Now().Add(time.Duration(authResp.ExpiresIn) * time.Second)
442+
443+ for time.Now().Before(deadline) {
444+ select {
445+ case <-ctx.Done():
446+ browser.CloseBrowser()
447+ return nil, ctx.Err()
448+ case <-time.After(interval):
449+ tokenResp, err := c.CreateTokenWithRegion(ctx, regResp.ClientID, regResp.ClientSecret, authResp.DeviceCode, region)
450+ if err != nil {
451+ if errors.Is(err, ErrAuthorizationPending) {
452+ fmt.Print(".")
453+ continue
454+ }
455+ if errors.Is(err, ErrSlowDown) {
456+ interval += 5 * time.Second
457+ continue
458+ }
459+ browser.CloseBrowser()
460+ return nil, fmt.Errorf("token creation failed: %w", err)
461+ }
462+
463+ fmt.Println("\n\n✓ Authorization successful!")
464+
465+ // Close the browser window
466+ if err := browser.CloseBrowser(); err != nil {
467+ log.Debugf("Failed to close browser: %v", err)
468+ }
469+
470+ // Step 5: Get profile ARN from CodeWhisperer API
471+ fmt.Println("Fetching profile information...")
472+ profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
473+
474+ // Fetch user email
475+ email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
476+ if email != "" {
477+ fmt.Printf(" Logged in as: %s\n", email)
478+ }
479+
480+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
481+
482+ return &KiroTokenData{
483+ AccessToken: tokenResp.AccessToken,
484+ RefreshToken: tokenResp.RefreshToken,
485+ ProfileArn: profileArn,
486+ ExpiresAt: expiresAt.Format(time.RFC3339),
487+ AuthMethod: "idc",
488+ Provider: "AWS",
489+ ClientID: regResp.ClientID,
490+ ClientSecret: regResp.ClientSecret,
491+ Email: email,
492+ StartURL: startURL,
493+ Region: region,
494+ }, nil
495+ }
496+ }
497+
498+ // Close browser on timeout
499+ if err := browser.CloseBrowser(); err != nil {
500+ log.Debugf("Failed to close browser on timeout: %v", err)
501+ }
502+ return nil, fmt.Errorf("authorization timed out")
503+}
504+
505+// LoginWithMethodSelection prompts the user to select between Builder ID and IDC, then performs the login.
506+func (c *SSOOIDCClient) LoginWithMethodSelection(ctx context.Context) (*KiroTokenData, error) {
507+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
508+ fmt.Println("║ Kiro Authentication (AWS) ║")
509+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
510+
511+ // Prompt for login method
512+ options := []string{
513+ "Use with Builder ID (personal AWS account)",
514+ "Use with IDC Account (organization SSO)",
515+ }
516+ selection := promptSelect("\n? Select login method:", options)
517+
518+ if selection == 0 {
519+ // Builder ID flow - use existing implementation
520+ return c.LoginWithBuilderID(ctx)
521+ }
522+
523+ // IDC flow - prompt for start URL and region
524+ fmt.Println()
525+ startURL := promptInput("? Enter Start URL", "")
526+ if startURL == "" {
527+ return nil, fmt.Errorf("start URL is required for IDC login")
528+ }
529+
530+ region := promptInput("? Enter Region", defaultIDCRegion)
531+
532+ return c.LoginWithIDC(ctx, startURL, region)
533+}
534+
535+// RegisterClient registers a new OIDC client with AWS.
536+func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResponse, error) {
537+ payload := map[string]interface{}{
538+ "clientName": "Kiro IDE",
539+ "clientType": "public",
540+ "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"},
541+ "grantTypes": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
542+ }
543+
544+ body, err := json.Marshal(payload)
545+ if err != nil {
546+ return nil, err
547+ }
548+
549+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/client/register", strings.NewReader(string(body)))
550+ if err != nil {
551+ return nil, err
552+ }
553+ req.Header.Set("Content-Type", "application/json")
554+ req.Header.Set("User-Agent", kiroUserAgent)
555+
556+ resp, err := c.httpClient.Do(req)
557+ if err != nil {
558+ return nil, err
559+ }
560+ defer resp.Body.Close()
561+
562+ respBody, err := io.ReadAll(resp.Body)
563+ if err != nil {
564+ return nil, err
565+ }
566+
567+ if resp.StatusCode != http.StatusOK {
568+ log.Debugf("register client failed (status %d): %s", resp.StatusCode, string(respBody))
569+ return nil, fmt.Errorf("register client failed (status %d)", resp.StatusCode)
570+ }
571+
572+ var result RegisterClientResponse
573+ if err := json.Unmarshal(respBody, &result); err != nil {
574+ return nil, err
575+ }
576+
577+ return &result, nil
578+}
579+
580+// StartDeviceAuthorization starts the device authorization flow.
581+func (c *SSOOIDCClient) StartDeviceAuthorization(ctx context.Context, clientID, clientSecret string) (*StartDeviceAuthResponse, error) {
582+ payload := map[string]string{
583+ "clientId": clientID,
584+ "clientSecret": clientSecret,
585+ "startUrl": builderIDStartURL,
586+ }
587+
588+ body, err := json.Marshal(payload)
589+ if err != nil {
590+ return nil, err
591+ }
592+
593+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/device_authorization", strings.NewReader(string(body)))
594+ if err != nil {
595+ return nil, err
596+ }
597+ req.Header.Set("Content-Type", "application/json")
598+ req.Header.Set("User-Agent", kiroUserAgent)
599+
600+ resp, err := c.httpClient.Do(req)
601+ if err != nil {
602+ return nil, err
603+ }
604+ defer resp.Body.Close()
605+
606+ respBody, err := io.ReadAll(resp.Body)
607+ if err != nil {
608+ return nil, err
609+ }
610+
611+ if resp.StatusCode != http.StatusOK {
612+ log.Debugf("start device auth failed (status %d): %s", resp.StatusCode, string(respBody))
613+ return nil, fmt.Errorf("start device auth failed (status %d)", resp.StatusCode)
614+ }
615+
616+ var result StartDeviceAuthResponse
617+ if err := json.Unmarshal(respBody, &result); err != nil {
618+ return nil, err
619+ }
620+
621+ return &result, nil
622+}
623+
624+// CreateToken polls for the access token after user authorization.
625+func (c *SSOOIDCClient) CreateToken(ctx context.Context, clientID, clientSecret, deviceCode string) (*CreateTokenResponse, error) {
626+ payload := map[string]string{
627+ "clientId": clientID,
628+ "clientSecret": clientSecret,
629+ "deviceCode": deviceCode,
630+ "grantType": "urn:ietf:params:oauth:grant-type:device_code",
631+ }
632+
633+ body, err := json.Marshal(payload)
634+ if err != nil {
635+ return nil, err
636+ }
637+
638+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/token", strings.NewReader(string(body)))
639+ if err != nil {
640+ return nil, err
641+ }
642+ req.Header.Set("Content-Type", "application/json")
643+ req.Header.Set("User-Agent", kiroUserAgent)
644+
645+ resp, err := c.httpClient.Do(req)
646+ if err != nil {
647+ return nil, err
648+ }
649+ defer resp.Body.Close()
650+
651+ respBody, err := io.ReadAll(resp.Body)
652+ if err != nil {
653+ return nil, err
654+ }
655+
656+ // Check for pending authorization
657+ if resp.StatusCode == http.StatusBadRequest {
658+ var errResp struct {
659+ Error string `json:"error"`
660+ }
661+ if json.Unmarshal(respBody, &errResp) == nil {
662+ if errResp.Error == "authorization_pending" {
663+ return nil, ErrAuthorizationPending
664+ }
665+ if errResp.Error == "slow_down" {
666+ return nil, ErrSlowDown
667+ }
668+ }
669+ log.Debugf("create token failed: %s", string(respBody))
670+ return nil, fmt.Errorf("create token failed")
671+ }
672+
673+ if resp.StatusCode != http.StatusOK {
674+ log.Debugf("create token failed (status %d): %s", resp.StatusCode, string(respBody))
675+ return nil, fmt.Errorf("create token failed (status %d)", resp.StatusCode)
676+ }
677+
678+ var result CreateTokenResponse
679+ if err := json.Unmarshal(respBody, &result); err != nil {
680+ return nil, err
681+ }
682+
683+ return &result, nil
684+}
685+
686+// RefreshToken refreshes an access token using the refresh token.
687+func (c *SSOOIDCClient) RefreshToken(ctx context.Context, clientID, clientSecret, refreshToken string) (*KiroTokenData, error) {
688+ payload := map[string]string{
689+ "clientId": clientID,
690+ "clientSecret": clientSecret,
691+ "refreshToken": refreshToken,
692+ "grantType": "refresh_token",
693+ }
694+
695+ body, err := json.Marshal(payload)
696+ if err != nil {
697+ return nil, err
698+ }
699+
700+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/token", strings.NewReader(string(body)))
701+ if err != nil {
702+ return nil, err
703+ }
704+ req.Header.Set("Content-Type", "application/json")
705+ req.Header.Set("User-Agent", kiroUserAgent)
706+
707+ resp, err := c.httpClient.Do(req)
708+ if err != nil {
709+ return nil, err
710+ }
711+ defer resp.Body.Close()
712+
713+ respBody, err := io.ReadAll(resp.Body)
714+ if err != nil {
715+ return nil, err
716+ }
717+
718+ if resp.StatusCode != http.StatusOK {
719+ log.Debugf("token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
720+ return nil, fmt.Errorf("token refresh failed (status %d)", resp.StatusCode)
721+ }
722+
723+ var result CreateTokenResponse
724+ if err := json.Unmarshal(respBody, &result); err != nil {
725+ return nil, err
726+ }
727+
728+ expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
729+
730+ return &KiroTokenData{
731+ AccessToken: result.AccessToken,
732+ RefreshToken: result.RefreshToken,
733+ ExpiresAt: expiresAt.Format(time.RFC3339),
734+ AuthMethod: "builder-id",
735+ Provider: "AWS",
736+ ClientID: clientID,
737+ ClientSecret: clientSecret,
738+ }, nil
739+}
740+
741+// LoginWithBuilderID performs the full device code flow for AWS Builder ID.
742+func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, error) {
743+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
744+ fmt.Println("║ Kiro Authentication (AWS Builder ID) ║")
745+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
746+
747+ // Step 1: Register client
748+ fmt.Println("\nRegistering client...")
749+ regResp, err := c.RegisterClient(ctx)
750+ if err != nil {
751+ return nil, fmt.Errorf("failed to register client: %w", err)
752+ }
753+ log.Debugf("Client registered: %s", regResp.ClientID)
754+
755+ // Step 2: Start device authorization
756+ fmt.Println("Starting device authorization...")
757+ authResp, err := c.StartDeviceAuthorization(ctx, regResp.ClientID, regResp.ClientSecret)
758+ if err != nil {
759+ return nil, fmt.Errorf("failed to start device auth: %w", err)
760+ }
761+
762+ // Step 3: Show user the verification URL
763+ fmt.Printf("\n")
764+ fmt.Println("════════════════════════════════════════════════════════════")
765+ fmt.Printf(" Open this URL in your browser:\n")
766+ fmt.Printf(" %s\n", authResp.VerificationURIComplete)
767+ fmt.Println("════════════════════════════════════════════════════════════")
768+ fmt.Printf("\n Or go to: %s\n", authResp.VerificationURI)
769+ fmt.Printf(" And enter code: %s\n\n", authResp.UserCode)
770+
771+ // Set incognito mode based on config (defaults to true for Kiro, can be overridden with --no-incognito)
772+ // Incognito mode enables multi-account support by bypassing cached sessions
773+ if c.cfg != nil {
774+ browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
775+ if !c.cfg.IncognitoBrowser {
776+ log.Info("kiro: using normal browser mode (--no-incognito). Note: You may not be able to select a different account.")
777+ } else {
778+ log.Debug("kiro: using incognito mode for multi-account support")
779+ }
780+ } else {
781+ browser.SetIncognitoMode(true) // Default to incognito if no config
782+ log.Debug("kiro: using incognito mode for multi-account support (default)")
783+ }
784+
785+ // Open browser using cross-platform browser package
786+ if err := browser.OpenURL(authResp.VerificationURIComplete); err != nil {
787+ log.Warnf("Could not open browser automatically: %v", err)
788+ fmt.Println(" Please open the URL manually in your browser.")
789+ } else {
790+ fmt.Println(" (Browser opened automatically)")
791+ }
792+
793+ // Step 4: Poll for token
794+ fmt.Println("Waiting for authorization...")
795+
796+ interval := pollInterval
797+ if authResp.Interval > 0 {
798+ interval = time.Duration(authResp.Interval) * time.Second
799+ }
800+
801+ deadline := time.Now().Add(time.Duration(authResp.ExpiresIn) * time.Second)
802+
803+ for time.Now().Before(deadline) {
804+ select {
805+ case <-ctx.Done():
806+ browser.CloseBrowser() // Cleanup on cancel
807+ return nil, ctx.Err()
808+ case <-time.After(interval):
809+ tokenResp, err := c.CreateToken(ctx, regResp.ClientID, regResp.ClientSecret, authResp.DeviceCode)
810+ if err != nil {
811+ if errors.Is(err, ErrAuthorizationPending) {
812+ fmt.Print(".")
813+ continue
814+ }
815+ if errors.Is(err, ErrSlowDown) {
816+ interval += 5 * time.Second
817+ continue
818+ }
819+ // Close browser on error before returning
820+ browser.CloseBrowser()
821+ return nil, fmt.Errorf("token creation failed: %w", err)
822+ }
823+
824+ fmt.Println("\n\n✓ Authorization successful!")
825+
826+ // Close the browser window
827+ if err := browser.CloseBrowser(); err != nil {
828+ log.Debugf("Failed to close browser: %v", err)
829+ }
830+
831+ // Step 5: Get profile ARN from CodeWhisperer API
832+ fmt.Println("Fetching profile information...")
833+ profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
834+
835+ // Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing)
836+ email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
837+ if email != "" {
838+ fmt.Printf(" Logged in as: %s\n", email)
839+ }
840+
841+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
842+
843+ return &KiroTokenData{
844+ AccessToken: tokenResp.AccessToken,
845+ RefreshToken: tokenResp.RefreshToken,
846+ ProfileArn: profileArn,
847+ ExpiresAt: expiresAt.Format(time.RFC3339),
848+ AuthMethod: "builder-id",
849+ Provider: "AWS",
850+ ClientID: regResp.ClientID,
851+ ClientSecret: regResp.ClientSecret,
852+ Email: email,
853+ }, nil
854+ }
855+ }
856+
857+ // Close browser on timeout for better UX
858+ if err := browser.CloseBrowser(); err != nil {
859+ log.Debugf("Failed to close browser on timeout: %v", err)
860+ }
861+ return nil, fmt.Errorf("authorization timed out")
862+}
863+
864+// FetchUserEmail retrieves the user's email from AWS SSO OIDC userinfo endpoint.
865+// Falls back to JWT parsing if userinfo fails.
866+func (c *SSOOIDCClient) FetchUserEmail(ctx context.Context, accessToken string) string {
867+ // Method 1: Try userinfo endpoint (standard OIDC)
868+ email := c.tryUserInfoEndpoint(ctx, accessToken)
869+ if email != "" {
870+ return email
871+ }
872+
873+ // Method 2: Fallback to JWT parsing
874+ return ExtractEmailFromJWT(accessToken)
875+}
876+
877+// tryUserInfoEndpoint attempts to get user info from AWS SSO OIDC userinfo endpoint.
878+func (c *SSOOIDCClient) tryUserInfoEndpoint(ctx context.Context, accessToken string) string {
879+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, ssoOIDCEndpoint+"/userinfo", nil)
880+ if err != nil {
881+ return ""
882+ }
883+ req.Header.Set("Authorization", "Bearer "+accessToken)
884+ req.Header.Set("Accept", "application/json")
885+
886+ resp, err := c.httpClient.Do(req)
887+ if err != nil {
888+ log.Debugf("userinfo request failed: %v", err)
889+ return ""
890+ }
891+ defer resp.Body.Close()
892+
893+ if resp.StatusCode != http.StatusOK {
894+ respBody, _ := io.ReadAll(resp.Body)
895+ log.Debugf("userinfo endpoint returned status %d: %s", resp.StatusCode, string(respBody))
896+ return ""
897+ }
898+
899+ respBody, err := io.ReadAll(resp.Body)
900+ if err != nil {
901+ return ""
902+ }
903+
904+ log.Debugf("userinfo response: %s", string(respBody))
905+
906+ var userInfo struct {
907+ Email string `json:"email"`
908+ Sub string `json:"sub"`
909+ PreferredUsername string `json:"preferred_username"`
910+ Name string `json:"name"`
911+ }
912+
913+ if err := json.Unmarshal(respBody, &userInfo); err != nil {
914+ return ""
915+ }
916+
917+ if userInfo.Email != "" {
918+ return userInfo.Email
919+ }
920+ if userInfo.PreferredUsername != "" && strings.Contains(userInfo.PreferredUsername, "@") {
921+ return userInfo.PreferredUsername
922+ }
923+ return ""
924+}
925+
926+// fetchProfileArn retrieves the profile ARN from CodeWhisperer API.
927+// This is needed for file naming since AWS SSO OIDC doesn't return profile info.
928+func (c *SSOOIDCClient) fetchProfileArn(ctx context.Context, accessToken string) string {
929+ // Try ListProfiles API first
930+ profileArn := c.tryListProfiles(ctx, accessToken)
931+ if profileArn != "" {
932+ return profileArn
933+ }
934+
935+ // Fallback: Try ListAvailableCustomizations
936+ return c.tryListCustomizations(ctx, accessToken)
937+}
938+
939+func (c *SSOOIDCClient) tryListProfiles(ctx context.Context, accessToken string) string {
940+ payload := map[string]interface{}{
941+ "origin": "AI_EDITOR",
942+ }
943+
944+ body, err := json.Marshal(payload)
945+ if err != nil {
946+ return ""
947+ }
948+
949+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://codewhisperer.us-east-1.amazonaws.com", strings.NewReader(string(body)))
950+ if err != nil {
951+ return ""
952+ }
953+
954+ req.Header.Set("Content-Type", "application/x-amz-json-1.0")
955+ req.Header.Set("x-amz-target", "AmazonCodeWhispererService.ListProfiles")
956+ req.Header.Set("Authorization", "Bearer "+accessToken)
957+ req.Header.Set("Accept", "application/json")
958+
959+ resp, err := c.httpClient.Do(req)
960+ if err != nil {
961+ return ""
962+ }
963+ defer resp.Body.Close()
964+
965+ respBody, _ := io.ReadAll(resp.Body)
966+
967+ if resp.StatusCode != http.StatusOK {
968+ log.Debugf("ListProfiles failed (status %d): %s", resp.StatusCode, string(respBody))
969+ return ""
970+ }
971+
972+ log.Debugf("ListProfiles response: %s", string(respBody))
973+
974+ var result struct {
975+ Profiles []struct {
976+ Arn string `json:"arn"`
977+ } `json:"profiles"`
978+ ProfileArn string `json:"profileArn"`
979+ }
980+
981+ if err := json.Unmarshal(respBody, &result); err != nil {
982+ return ""
983+ }
984+
985+ if result.ProfileArn != "" {
986+ return result.ProfileArn
987+ }
988+
989+ if len(result.Profiles) > 0 {
990+ return result.Profiles[0].Arn
991+ }
992+
993+ return ""
994+}
995+
996+func (c *SSOOIDCClient) tryListCustomizations(ctx context.Context, accessToken string) string {
997+ payload := map[string]interface{}{
998+ "origin": "AI_EDITOR",
999+ }
1000+
1001+ body, err := json.Marshal(payload)
1002+ if err != nil {
1003+ return ""
1004+ }
1005+
1006+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://codewhisperer.us-east-1.amazonaws.com", strings.NewReader(string(body)))
1007+ if err != nil {
1008+ return ""
1009+ }
1010+
1011+ req.Header.Set("Content-Type", "application/x-amz-json-1.0")
1012+ req.Header.Set("x-amz-target", "AmazonCodeWhispererService.ListAvailableCustomizations")
1013+ req.Header.Set("Authorization", "Bearer "+accessToken)
1014+ req.Header.Set("Accept", "application/json")
1015+
1016+ resp, err := c.httpClient.Do(req)
1017+ if err != nil {
1018+ return ""
1019+ }
1020+ defer resp.Body.Close()
1021+
1022+ respBody, _ := io.ReadAll(resp.Body)
1023+
1024+ if resp.StatusCode != http.StatusOK {
1025+ log.Debugf("ListAvailableCustomizations failed (status %d): %s", resp.StatusCode, string(respBody))
1026+ return ""
1027+ }
1028+
1029+ log.Debugf("ListAvailableCustomizations response: %s", string(respBody))
1030+
1031+ var result struct {
1032+ Customizations []struct {
1033+ Arn string `json:"arn"`
1034+ } `json:"customizations"`
1035+ ProfileArn string `json:"profileArn"`
1036+ }
1037+
1038+ if err := json.Unmarshal(respBody, &result); err != nil {
1039+ return ""
1040+ }
1041+
1042+ if result.ProfileArn != "" {
1043+ return result.ProfileArn
1044+ }
1045+
1046+ if len(result.Customizations) > 0 {
1047+ return result.Customizations[0].Arn
1048+ }
1049+
1050+ return ""
1051+}
1052+
1053+// RegisterClientForAuthCode registers a new OIDC client for authorization code flow.
1054+func (c *SSOOIDCClient) RegisterClientForAuthCode(ctx context.Context, redirectURI string) (*RegisterClientResponse, error) {
1055+ payload := map[string]interface{}{
1056+ "clientName": "Kiro IDE",
1057+ "clientType": "public",
1058+ "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"},
1059+ "grantTypes": []string{"authorization_code", "refresh_token"},
1060+ "redirectUris": []string{redirectURI},
1061+ "issuerUrl": builderIDStartURL,
1062+ }
1063+
1064+ body, err := json.Marshal(payload)
1065+ if err != nil {
1066+ return nil, err
1067+ }
1068+
1069+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/client/register", strings.NewReader(string(body)))
1070+ if err != nil {
1071+ return nil, err
1072+ }
1073+ req.Header.Set("Content-Type", "application/json")
1074+ req.Header.Set("User-Agent", kiroUserAgent)
1075+
1076+ resp, err := c.httpClient.Do(req)
1077+ if err != nil {
1078+ return nil, err
1079+ }
1080+ defer resp.Body.Close()
1081+
1082+ respBody, err := io.ReadAll(resp.Body)
1083+ if err != nil {
1084+ return nil, err
1085+ }
1086+
1087+ if resp.StatusCode != http.StatusOK {
1088+ log.Debugf("register client for auth code failed (status %d): %s", resp.StatusCode, string(respBody))
1089+ return nil, fmt.Errorf("register client failed (status %d)", resp.StatusCode)
1090+ }
1091+
1092+ var result RegisterClientResponse
1093+ if err := json.Unmarshal(respBody, &result); err != nil {
1094+ return nil, err
1095+ }
1096+
1097+ return &result, nil
1098+}
1099+
1100+// AuthCodeCallbackResult contains the result from authorization code callback.
1101+type AuthCodeCallbackResult struct {
1102+ Code string
1103+ State string
1104+ Error string
1105+}
1106+
1107+// startAuthCodeCallbackServer starts a local HTTP server to receive the authorization code callback.
1108+func (c *SSOOIDCClient) startAuthCodeCallbackServer(ctx context.Context, expectedState string) (string, <-chan AuthCodeCallbackResult, error) {
1109+ // Try to find an available port
1110+ listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", authCodeCallbackPort))
1111+ if err != nil {
1112+ // Try with dynamic port
1113+ log.Warnf("sso oidc: default port %d is busy, falling back to dynamic port", authCodeCallbackPort)
1114+ listener, err = net.Listen("tcp", "127.0.0.1:0")
1115+ if err != nil {
1116+ return "", nil, fmt.Errorf("failed to start callback server: %w", err)
1117+ }
1118+ }
1119+
1120+ port := listener.Addr().(*net.TCPAddr).Port
1121+ redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, authCodeCallbackPath)
1122+ resultChan := make(chan AuthCodeCallbackResult, 1)
1123+
1124+ server := &http.Server{
1125+ ReadHeaderTimeout: 10 * time.Second,
1126+ }
1127+
1128+ mux := http.NewServeMux()
1129+ mux.HandleFunc(authCodeCallbackPath, func(w http.ResponseWriter, r *http.Request) {
1130+ code := r.URL.Query().Get("code")
1131+ state := r.URL.Query().Get("state")
1132+ errParam := r.URL.Query().Get("error")
1133+
1134+ // Send response to browser
1135+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
1136+ if errParam != "" {
1137+ w.WriteHeader(http.StatusBadRequest)
1138+ fmt.Fprintf(w, `<!DOCTYPE html>
1139+<html><head><title>Login Failed</title></head>
1140+<body><h1>Login Failed</h1><p>Error: %s</p><p>You can close this window.</p></body></html>`, html.EscapeString(errParam))
1141+ resultChan <- AuthCodeCallbackResult{Error: errParam}
1142+ return
1143+ }
1144+
1145+ if state != expectedState {
1146+ w.WriteHeader(http.StatusBadRequest)
1147+ fmt.Fprint(w, `<!DOCTYPE html>
1148+<html><head><title>Login Failed</title></head>
1149+<body><h1>Login Failed</h1><p>Invalid state parameter</p><p>You can close this window.</p></body></html>`)
1150+ resultChan <- AuthCodeCallbackResult{Error: "state mismatch"}
1151+ return
1152+ }
1153+
1154+ fmt.Fprint(w, `<!DOCTYPE html>
1155+<html><head><title>Login Successful</title></head>
1156+<body><h1>Login Successful!</h1><p>You can close this window and return to the terminal.</p>
1157+<script>window.close();</script></body></html>`)
1158+ resultChan <- AuthCodeCallbackResult{Code: code, State: state}
1159+ })
1160+
1161+ server.Handler = mux
1162+
1163+ go func() {
1164+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
1165+ log.Debugf("auth code callback server error: %v", err)
1166+ }
1167+ }()
1168+
1169+ go func() {
1170+ select {
1171+ case <-ctx.Done():
1172+ case <-time.After(10 * time.Minute):
1173+ case <-resultChan:
1174+ }
1175+ _ = server.Shutdown(context.Background())
1176+ }()
1177+
1178+ return redirectURI, resultChan, nil
1179+}
1180+
1181+// generatePKCEForAuthCode generates PKCE code verifier and challenge for authorization code flow.
1182+func generatePKCEForAuthCode() (verifier, challenge string, err error) {
1183+ b := make([]byte, 32)
1184+ if _, err := rand.Read(b); err != nil {
1185+ return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
1186+ }
1187+ verifier = base64.RawURLEncoding.EncodeToString(b)
1188+ h := sha256.Sum256([]byte(verifier))
1189+ challenge = base64.RawURLEncoding.EncodeToString(h[:])
1190+ return verifier, challenge, nil
1191+}
1192+
1193+// generateStateForAuthCode generates a random state parameter.
1194+func generateStateForAuthCode() (string, error) {
1195+ b := make([]byte, 16)
1196+ if _, err := rand.Read(b); err != nil {
1197+ return "", err
1198+ }
1199+ return base64.RawURLEncoding.EncodeToString(b), nil
1200+}
1201+
1202+// CreateTokenWithAuthCode exchanges authorization code for tokens.
1203+func (c *SSOOIDCClient) CreateTokenWithAuthCode(ctx context.Context, clientID, clientSecret, code, codeVerifier, redirectURI string) (*CreateTokenResponse, error) {
1204+ payload := map[string]string{
1205+ "clientId": clientID,
1206+ "clientSecret": clientSecret,
1207+ "code": code,
1208+ "codeVerifier": codeVerifier,
1209+ "redirectUri": redirectURI,
1210+ "grantType": "authorization_code",
1211+ }
1212+
1213+ body, err := json.Marshal(payload)
1214+ if err != nil {
1215+ return nil, err
1216+ }
1217+
1218+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/token", strings.NewReader(string(body)))
1219+ if err != nil {
1220+ return nil, err
1221+ }
1222+ req.Header.Set("Content-Type", "application/json")
1223+ req.Header.Set("User-Agent", kiroUserAgent)
1224+
1225+ resp, err := c.httpClient.Do(req)
1226+ if err != nil {
1227+ return nil, err
1228+ }
1229+ defer resp.Body.Close()
1230+
1231+ respBody, err := io.ReadAll(resp.Body)
1232+ if err != nil {
1233+ return nil, err
1234+ }
1235+
1236+ if resp.StatusCode != http.StatusOK {
1237+ log.Debugf("create token with auth code failed (status %d): %s", resp.StatusCode, string(respBody))
1238+ return nil, fmt.Errorf("create token failed (status %d)", resp.StatusCode)
1239+ }
1240+
1241+ var result CreateTokenResponse
1242+ if err := json.Unmarshal(respBody, &result); err != nil {
1243+ return nil, err
1244+ }
1245+
1246+ return &result, nil
1247+}
1248+
1249+// LoginWithBuilderIDAuthCode performs the authorization code flow for AWS Builder ID.
1250+// This provides a better UX than device code flow as it uses automatic browser callback.
1251+func (c *SSOOIDCClient) LoginWithBuilderIDAuthCode(ctx context.Context) (*KiroTokenData, error) {
1252+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
1253+ fmt.Println("║ Kiro Authentication (AWS Builder ID - Auth Code) ║")
1254+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
1255+
1256+ // Step 1: Generate PKCE and state
1257+ codeVerifier, codeChallenge, err := generatePKCEForAuthCode()
1258+ if err != nil {
1259+ return nil, fmt.Errorf("failed to generate PKCE: %w", err)
1260+ }
1261+
1262+ state, err := generateStateForAuthCode()
1263+ if err != nil {
1264+ return nil, fmt.Errorf("failed to generate state: %w", err)
1265+ }
1266+
1267+ // Step 2: Start callback server
1268+ fmt.Println("\nStarting callback server...")
1269+ redirectURI, resultChan, err := c.startAuthCodeCallbackServer(ctx, state)
1270+ if err != nil {
1271+ return nil, fmt.Errorf("failed to start callback server: %w", err)
1272+ }
1273+ log.Debugf("Callback server started, redirect URI: %s", redirectURI)
1274+
1275+ // Step 3: Register client with auth code grant type
1276+ fmt.Println("Registering client...")
1277+ regResp, err := c.RegisterClientForAuthCode(ctx, redirectURI)
1278+ if err != nil {
1279+ return nil, fmt.Errorf("failed to register client: %w", err)
1280+ }
1281+ log.Debugf("Client registered: %s", regResp.ClientID)
1282+
1283+ // Step 4: Build authorization URL
1284+ scopes := "codewhisperer:completions,codewhisperer:analysis,codewhisperer:conversations"
1285+ authURL := fmt.Sprintf("%s/authorize?response_type=code&client_id=%s&redirect_uri=%s&scopes=%s&state=%s&code_challenge=%s&code_challenge_method=S256",
1286+ ssoOIDCEndpoint,
1287+ regResp.ClientID,
1288+ redirectURI,
1289+ scopes,
1290+ state,
1291+ codeChallenge,
1292+ )
1293+
1294+ // Step 5: Open browser
1295+ fmt.Println("\n════════════════════════════════════════════════════════════")
1296+ fmt.Println(" Opening browser for authentication...")
1297+ fmt.Println("════════════════════════════════════════════════════════════")
1298+ fmt.Printf("\n URL: %s\n\n", authURL)
1299+
1300+ // Set incognito mode
1301+ if c.cfg != nil {
1302+ browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
1303+ } else {
1304+ browser.SetIncognitoMode(true)
1305+ }
1306+
1307+ if err := browser.OpenURL(authURL); err != nil {
1308+ log.Warnf("Could not open browser automatically: %v", err)
1309+ fmt.Println(" ⚠ Could not open browser automatically.")
1310+ fmt.Println(" Please open the URL above in your browser manually.")
1311+ } else {
1312+ fmt.Println(" (Browser opened automatically)")
1313+ }
1314+
1315+ fmt.Println("\n Waiting for authorization callback...")
1316+
1317+ // Step 6: Wait for callback
1318+ select {
1319+ case <-ctx.Done():
1320+ browser.CloseBrowser()
1321+ return nil, ctx.Err()
1322+ case <-time.After(10 * time.Minute):
1323+ browser.CloseBrowser()
1324+ return nil, fmt.Errorf("authorization timed out")
1325+ case result := <-resultChan:
1326+ if result.Error != "" {
1327+ browser.CloseBrowser()
1328+ return nil, fmt.Errorf("authorization failed: %s", result.Error)
1329+ }
1330+
1331+ fmt.Println("\n✓ Authorization received!")
1332+
1333+ // Close browser
1334+ if err := browser.CloseBrowser(); err != nil {
1335+ log.Debugf("Failed to close browser: %v", err)
1336+ }
1337+
1338+ // Step 7: Exchange code for tokens
1339+ fmt.Println("Exchanging code for tokens...")
1340+ tokenResp, err := c.CreateTokenWithAuthCode(ctx, regResp.ClientID, regResp.ClientSecret, result.Code, codeVerifier, redirectURI)
1341+ if err != nil {
1342+ return nil, fmt.Errorf("failed to exchange code for tokens: %w", err)
1343+ }
1344+
1345+ fmt.Println("\n✓ Authentication successful!")
1346+
1347+ // Step 8: Get profile ARN
1348+ fmt.Println("Fetching profile information...")
1349+ profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
1350+
1351+ // Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing)
1352+ email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
1353+ if email != "" {
1354+ fmt.Printf(" Logged in as: %s\n", email)
1355+ }
1356+
1357+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
1358+
1359+ return &KiroTokenData{
1360+ AccessToken: tokenResp.AccessToken,
1361+ RefreshToken: tokenResp.RefreshToken,
1362+ ProfileArn: profileArn,
1363+ ExpiresAt: expiresAt.Format(time.RFC3339),
1364+ AuthMethod: "builder-id",
1365+ Provider: "AWS",
1366+ ClientID: regResp.ClientID,
1367+ ClientSecret: regResp.ClientSecret,
1368+ Email: email,
1369+ }, nil
1370+ }
1371+}
internal/auth/kiro/token.go+72−0
@@ -0,0 +1,72 @@
1+package kiro
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+)
9+
10+// KiroTokenStorage holds the persistent token data for Kiro authentication.
11+type KiroTokenStorage struct {
12+ // AccessToken is the OAuth2 access token for API access
13+ AccessToken string `json:"access_token"`
14+ // RefreshToken is used to obtain new access tokens
15+ RefreshToken string `json:"refresh_token"`
16+ // ProfileArn is the AWS CodeWhisperer profile ARN
17+ ProfileArn string `json:"profile_arn"`
18+ // ExpiresAt is the timestamp when the token expires
19+ ExpiresAt string `json:"expires_at"`
20+ // AuthMethod indicates the authentication method used
21+ AuthMethod string `json:"auth_method"`
22+ // Provider indicates the OAuth provider
23+ Provider string `json:"provider"`
24+ // LastRefresh is the timestamp of the last token refresh
25+ LastRefresh string `json:"last_refresh"`
26+}
27+
28+// SaveTokenToFile persists the token storage to the specified file path.
29+func (s *KiroTokenStorage) SaveTokenToFile(authFilePath string) error {
30+ dir := filepath.Dir(authFilePath)
31+ if err := os.MkdirAll(dir, 0700); err != nil {
32+ return fmt.Errorf("failed to create directory: %w", err)
33+ }
34+
35+ data, err := json.MarshalIndent(s, "", " ")
36+ if err != nil {
37+ return fmt.Errorf("failed to marshal token storage: %w", err)
38+ }
39+
40+ if err := os.WriteFile(authFilePath, data, 0600); err != nil {
41+ return fmt.Errorf("failed to write token file: %w", err)
42+ }
43+
44+ return nil
45+}
46+
47+// LoadFromFile loads token storage from the specified file path.
48+func LoadFromFile(authFilePath string) (*KiroTokenStorage, error) {
49+ data, err := os.ReadFile(authFilePath)
50+ if err != nil {
51+ return nil, fmt.Errorf("failed to read token file: %w", err)
52+ }
53+
54+ var storage KiroTokenStorage
55+ if err := json.Unmarshal(data, &storage); err != nil {
56+ return nil, fmt.Errorf("failed to parse token file: %w", err)
57+ }
58+
59+ return &storage, nil
60+}
61+
62+// ToTokenData converts storage to KiroTokenData for API use.
63+func (s *KiroTokenStorage) ToTokenData() *KiroTokenData {
64+ return &KiroTokenData{
65+ AccessToken: s.AccessToken,
66+ RefreshToken: s.RefreshToken,
67+ ProfileArn: s.ProfileArn,
68+ ExpiresAt: s.ExpiresAt,
69+ AuthMethod: s.AuthMethod,
70+ Provider: s.Provider,
71+ }
72+}
internal/browser/browser.go+416−14
@@ -6,14 +6,49 @@ import ( · 5 unmodified lines
66 "fmt"
77 "os/exec"
88 "runtime"
9+ "strings"
10+ "sync"
911
12+ pkgbrowser "github.com/pkg/browser"
1013 log "github.com/sirupsen/logrus"
11− "github.com/skratchdot/open-golang/open"
1214 )
1315
16+// incognitoMode controls whether to open URLs in incognito/private mode.
17+// This is useful for OAuth flows where you want to use a different account.
18+var incognitoMode bool
19+
20+// lastBrowserProcess stores the last opened browser process for cleanup
21+var lastBrowserProcess *exec.Cmd
22+var browserMutex sync.Mutex
23+
24+// SetIncognitoMode enables or disables incognito/private browsing mode.
25+func SetIncognitoMode(enabled bool) {
26+ incognitoMode = enabled
27+}
28+
29+// IsIncognitoMode returns whether incognito mode is enabled.
30+func IsIncognitoMode() bool {
31+ return incognitoMode
32+}
33+
34+// CloseBrowser closes the last opened browser process.
35+func CloseBrowser() error {
36+ browserMutex.Lock()
37+ defer browserMutex.Unlock()
38+
39+ if lastBrowserProcess == nil || lastBrowserProcess.Process == nil {
40+ return nil
41+ }
42+
43+ err := lastBrowserProcess.Process.Kill()
44+ lastBrowserProcess = nil
45+ return err
46+}
47+
1448 // OpenURL opens the specified URL in the default web browser.
15−// It first attempts to use a platform-agnostic library and falls back to
16−// platform-specific commands if that fails.
49+// It uses the pkg/browser library which provides robust cross-platform support
50+// for Windows, macOS, and Linux.
51+// If incognito mode is enabled, it will open in a private/incognito window.
1752 //
1853 // Parameters:
1954 // - url: The URL to open.
@@ -21,16 +56,22 @@ import ( · 1 unmodified line
2156 // Returns:
2257 // - An error if the URL cannot be opened, otherwise nil.
2358 func OpenURL(url string) error {
24− fmt.Printf("Attempting to open URL in browser: %s\n", url)
59+ log.Debugf("Opening URL in browser: %s (incognito=%v)", url, incognitoMode)
2560
26− // Try using the open-golang library first
27− err := open.Run(url)
61+ // If incognito mode is enabled, use platform-specific incognito commands
62+ if incognitoMode {
63+ log.Debug("Using incognito mode")
64+ return openURLIncognito(url)
65+ }
66+
67+ // Use pkg/browser for cross-platform support
68+ err := pkgbrowser.OpenURL(url)
2869 if err == nil {
29− log.Debug("Successfully opened URL using open-golang library")
70+ log.Debug("Successfully opened URL using pkg/browser library")
3071 return nil
3172 }
3273
33− log.Debugf("open-golang failed: %v, trying platform-specific commands", err)
74+ log.Debugf("pkg/browser failed: %v, trying platform-specific commands", err)
3475
3576 // Fallback to platform-specific commands
3677 return openURLPlatformSpecific(url)
@@ -78,18 +119,379 @@ func openURLPlatformSpecific(url string) error { · 41 unmodified lines
78119 return nil
79120 }
80121
122+// openURLIncognito opens a URL in incognito/private browsing mode.
123+// It first tries to detect the default browser and use its incognito flag.
124+// Falls back to a chain of known browsers if detection fails.
125+//
126+// Parameters:
127+// - url: The URL to open.
128+//
129+// Returns:
130+// - An error if the URL cannot be opened, otherwise nil.
131+func openURLIncognito(url string) error {
132+ // First, try to detect and use the default browser
133+ if cmd := tryDefaultBrowserIncognito(url); cmd != nil {
134+ log.Debugf("Using detected default browser: %s %v", cmd.Path, cmd.Args[1:])
135+ if err := cmd.Start(); err == nil {
136+ storeBrowserProcess(cmd)
137+ log.Debug("Successfully opened URL in default browser's incognito mode")
138+ return nil
139+ }
140+ log.Debugf("Failed to start default browser, trying fallback chain")
141+ }
142+
143+ // Fallback to known browser chain
144+ cmd := tryFallbackBrowsersIncognito(url)
145+ if cmd == nil {
146+ log.Warn("No browser with incognito support found, falling back to normal mode")
147+ return openURLPlatformSpecific(url)
148+ }
149+
150+ log.Debugf("Running incognito command: %s %v", cmd.Path, cmd.Args[1:])
151+ err := cmd.Start()
152+ if err != nil {
153+ log.Warnf("Failed to open incognito browser: %v, falling back to normal mode", err)
154+ return openURLPlatformSpecific(url)
155+ }
156+
157+ storeBrowserProcess(cmd)
158+ log.Debug("Successfully opened URL in incognito/private mode")
159+ return nil
160+}
161+
162+// storeBrowserProcess safely stores the browser process for later cleanup.
163+func storeBrowserProcess(cmd *exec.Cmd) {
164+ browserMutex.Lock()
165+ lastBrowserProcess = cmd
166+ browserMutex.Unlock()
167+}
168+
169+// tryDefaultBrowserIncognito attempts to detect the default browser and return
170+// an exec.Cmd configured with the appropriate incognito flag.
171+func tryDefaultBrowserIncognito(url string) *exec.Cmd {
172+ switch runtime.GOOS {
173+ case "darwin":
174+ return tryDefaultBrowserMacOS(url)
175+ case "windows":
176+ return tryDefaultBrowserWindows(url)
177+ case "linux":
178+ return tryDefaultBrowserLinux(url)
179+ }
180+ return nil
181+}
182+
183+// tryDefaultBrowserMacOS detects the default browser on macOS.
184+func tryDefaultBrowserMacOS(url string) *exec.Cmd {
185+ // Try to get default browser from Launch Services
186+ out, err := exec.Command("defaults", "read", "com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers").Output()
187+ if err != nil {
188+ return nil
189+ }
190+
191+ output := string(out)
192+ var browserName string
193+
194+ // Parse the output to find the http/https handler
195+ if containsBrowserID(output, "com.google.chrome") {
196+ browserName = "chrome"
197+ } else if containsBrowserID(output, "org.mozilla.firefox") {
198+ browserName = "firefox"
199+ } else if containsBrowserID(output, "com.apple.safari") {
200+ browserName = "safari"
201+ } else if containsBrowserID(output, "com.brave.browser") {
202+ browserName = "brave"
203+ } else if containsBrowserID(output, "com.microsoft.edgemac") {
204+ browserName = "edge"
205+ }
206+
207+ return createMacOSIncognitoCmd(browserName, url)
208+}
209+
210+// containsBrowserID checks if the LaunchServices output contains a browser ID.
211+func containsBrowserID(output, bundleID string) bool {
212+ return strings.Contains(output, bundleID)
213+}
214+
215+// createMacOSIncognitoCmd creates the appropriate incognito command for macOS browsers.
216+func createMacOSIncognitoCmd(browserName, url string) *exec.Cmd {
217+ switch browserName {
218+ case "chrome":
219+ // Try direct path first
220+ chromePath := "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
221+ if _, err := exec.LookPath(chromePath); err == nil {
222+ return exec.Command(chromePath, "--incognito", url)
223+ }
224+ return exec.Command("open", "-na", "Google Chrome", "--args", "--incognito", url)
225+ case "firefox":
226+ return exec.Command("open", "-na", "Firefox", "--args", "--private-window", url)
227+ case "safari":
228+ // Safari doesn't have CLI incognito, try AppleScript
229+ return tryAppleScriptSafariPrivate(url)
230+ case "brave":
231+ return exec.Command("open", "-na", "Brave Browser", "--args", "--incognito", url)
232+ case "edge":
233+ return exec.Command("open", "-na", "Microsoft Edge", "--args", "--inprivate", url)
234+ }
235+ return nil
236+}
237+
238+// tryAppleScriptSafariPrivate attempts to open Safari in private browsing mode using AppleScript.
239+func tryAppleScriptSafariPrivate(url string) *exec.Cmd {
240+ // AppleScript to open a new private window in Safari
241+ script := fmt.Sprintf(`
242+ tell application "Safari"
243+ activate
244+ tell application "System Events"
245+ keystroke "n" using {command down, shift down}
246+ delay 0.5
247+ end tell
248+ set URL of document 1 to "%s"
249+ end tell
250+ `, url)
251+
252+ cmd := exec.Command("osascript", "-e", script)
253+ // Test if this approach works by checking if Safari is available
254+ if _, err := exec.LookPath("/Applications/Safari.app/Contents/MacOS/Safari"); err != nil {
255+ log.Debug("Safari not found, AppleScript private window not available")
256+ return nil
257+ }
258+ log.Debug("Attempting Safari private window via AppleScript")
259+ return cmd
260+}
261+
262+// tryDefaultBrowserWindows detects the default browser on Windows via registry.
263+func tryDefaultBrowserWindows(url string) *exec.Cmd {
264+ // Query registry for default browser
265+ out, err := exec.Command("reg", "query",
266+ `HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice`,
267+ "/v", "ProgId").Output()
268+ if err != nil {
269+ return nil
270+ }
271+
272+ output := string(out)
273+ var browserName string
274+
275+ // Map ProgId to browser name
276+ if strings.Contains(output, "ChromeHTML") {
277+ browserName = "chrome"
278+ } else if strings.Contains(output, "FirefoxURL") {
279+ browserName = "firefox"
280+ } else if strings.Contains(output, "MSEdgeHTM") {
281+ browserName = "edge"
282+ } else if strings.Contains(output, "BraveHTML") {
283+ browserName = "brave"
284+ }
285+
286+ return createWindowsIncognitoCmd(browserName, url)
287+}
288+
289+// createWindowsIncognitoCmd creates the appropriate incognito command for Windows browsers.
290+func createWindowsIncognitoCmd(browserName, url string) *exec.Cmd {
291+ switch browserName {
292+ case "chrome":
293+ paths := []string{
294+ "chrome",
295+ `C:\Program Files\Google\Chrome\Application\chrome.exe`,
296+ `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
297+ }
298+ for _, p := range paths {
299+ if _, err := exec.LookPath(p); err == nil {
300+ return exec.Command(p, "--incognito", url)
301+ }
302+ }
303+ case "firefox":
304+ if path, err := exec.LookPath("firefox"); err == nil {
305+ return exec.Command(path, "--private-window", url)
306+ }
307+ case "edge":
308+ paths := []string{
309+ "msedge",
310+ `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
311+ `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
312+ }
313+ for _, p := range paths {
314+ if _, err := exec.LookPath(p); err == nil {
315+ return exec.Command(p, "--inprivate", url)
316+ }
317+ }
318+ case "brave":
319+ paths := []string{
320+ `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
321+ `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
322+ }
323+ for _, p := range paths {
324+ if _, err := exec.LookPath(p); err == nil {
325+ return exec.Command(p, "--incognito", url)
326+ }
327+ }
328+ }
329+ return nil
330+}
331+
332+// tryDefaultBrowserLinux detects the default browser on Linux using xdg-settings.
333+func tryDefaultBrowserLinux(url string) *exec.Cmd {
334+ out, err := exec.Command("xdg-settings", "get", "default-web-browser").Output()
335+ if err != nil {
336+ return nil
337+ }
338+
339+ desktop := string(out)
340+ var browserName string
341+
342+ // Map .desktop file to browser name
343+ if strings.Contains(desktop, "google-chrome") || strings.Contains(desktop, "chrome") {
344+ browserName = "chrome"
345+ } else if strings.Contains(desktop, "firefox") {
346+ browserName = "firefox"
347+ } else if strings.Contains(desktop, "chromium") {
348+ browserName = "chromium"
349+ } else if strings.Contains(desktop, "brave") {
350+ browserName = "brave"
351+ } else if strings.Contains(desktop, "microsoft-edge") || strings.Contains(desktop, "msedge") {
352+ browserName = "edge"
353+ }
354+
355+ return createLinuxIncognitoCmd(browserName, url)
356+}
357+
358+// createLinuxIncognitoCmd creates the appropriate incognito command for Linux browsers.
359+func createLinuxIncognitoCmd(browserName, url string) *exec.Cmd {
360+ switch browserName {
361+ case "chrome":
362+ paths := []string{"google-chrome", "google-chrome-stable"}
363+ for _, p := range paths {
364+ if path, err := exec.LookPath(p); err == nil {
365+ return exec.Command(path, "--incognito", url)
366+ }
367+ }
368+ case "firefox":
369+ paths := []string{"firefox", "firefox-esr"}
370+ for _, p := range paths {
371+ if path, err := exec.LookPath(p); err == nil {
372+ return exec.Command(path, "--private-window", url)
373+ }
374+ }
375+ case "chromium":
376+ paths := []string{"chromium", "chromium-browser"}
377+ for _, p := range paths {
378+ if path, err := exec.LookPath(p); err == nil {
379+ return exec.Command(path, "--incognito", url)
380+ }
381+ }
382+ case "brave":
383+ if path, err := exec.LookPath("brave-browser"); err == nil {
384+ return exec.Command(path, "--incognito", url)
385+ }
386+ case "edge":
387+ if path, err := exec.LookPath("microsoft-edge"); err == nil {
388+ return exec.Command(path, "--inprivate", url)
389+ }
390+ }
391+ return nil
392+}
393+
394+// tryFallbackBrowsersIncognito tries a chain of known browsers as fallback.
395+func tryFallbackBrowsersIncognito(url string) *exec.Cmd {
396+ switch runtime.GOOS {
397+ case "darwin":
398+ return tryFallbackBrowsersMacOS(url)
399+ case "windows":
400+ return tryFallbackBrowsersWindows(url)
401+ case "linux":
402+ return tryFallbackBrowsersLinuxChain(url)
403+ }
404+ return nil
405+}
406+
407+// tryFallbackBrowsersMacOS tries known browsers on macOS.
408+func tryFallbackBrowsersMacOS(url string) *exec.Cmd {
409+ // Try Chrome
410+ chromePath := "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
411+ if _, err := exec.LookPath(chromePath); err == nil {
412+ return exec.Command(chromePath, "--incognito", url)
413+ }
414+ // Try Firefox
415+ if _, err := exec.LookPath("/Applications/Firefox.app/Contents/MacOS/firefox"); err == nil {
416+ return exec.Command("open", "-na", "Firefox", "--args", "--private-window", url)
417+ }
418+ // Try Brave
419+ if _, err := exec.LookPath("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"); err == nil {
420+ return exec.Command("open", "-na", "Brave Browser", "--args", "--incognito", url)
421+ }
422+ // Try Edge
423+ if _, err := exec.LookPath("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"); err == nil {
424+ return exec.Command("open", "-na", "Microsoft Edge", "--args", "--inprivate", url)
425+ }
426+ // Last resort: try Safari with AppleScript
427+ if cmd := tryAppleScriptSafariPrivate(url); cmd != nil {
428+ log.Info("Using Safari with AppleScript for private browsing (may require accessibility permissions)")
429+ return cmd
430+ }
431+ return nil
432+}
433+
434+// tryFallbackBrowsersWindows tries known browsers on Windows.
435+func tryFallbackBrowsersWindows(url string) *exec.Cmd {
436+ // Chrome
437+ chromePaths := []string{
438+ "chrome",
439+ `C:\Program Files\Google\Chrome\Application\chrome.exe`,
440+ `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
441+ }
442+ for _, p := range chromePaths {
443+ if _, err := exec.LookPath(p); err == nil {
444+ return exec.Command(p, "--incognito", url)
445+ }
446+ }
447+ // Firefox
448+ if path, err := exec.LookPath("firefox"); err == nil {
449+ return exec.Command(path, "--private-window", url)
450+ }
451+ // Edge (usually available on Windows 10+)
452+ edgePaths := []string{
453+ "msedge",
454+ `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
455+ `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
456+ }
457+ for _, p := range edgePaths {
458+ if _, err := exec.LookPath(p); err == nil {
459+ return exec.Command(p, "--inprivate", url)
460+ }
461+ }
462+ return nil
463+}
464+
465+// tryFallbackBrowsersLinuxChain tries known browsers on Linux.
466+func tryFallbackBrowsersLinuxChain(url string) *exec.Cmd {
467+ type browserConfig struct {
468+ name string
469+ flag string
470+ }
471+ browsers := []browserConfig{
472+ {"google-chrome", "--incognito"},
473+ {"google-chrome-stable", "--incognito"},
474+ {"chromium", "--incognito"},
475+ {"chromium-browser", "--incognito"},
476+ {"firefox", "--private-window"},
477+ {"firefox-esr", "--private-window"},
478+ {"brave-browser", "--incognito"},
479+ {"microsoft-edge", "--inprivate"},
480+ }
481+ for _, b := range browsers {
482+ if path, err := exec.LookPath(b.name); err == nil {
483+ return exec.Command(path, b.flag, url)
484+ }
485+ }
486+ return nil
487+}
488+
81489 // IsAvailable checks if the system has a command available to open a web browser.
82490 // It verifies the presence of necessary commands for the current operating system.
83491 //
84492 // Returns:
85493 // - true if a browser can be opened, false otherwise.
86494 func IsAvailable() bool {
87− // First check if open-golang can work
88− testErr := open.Run("about:blank")
89− if testErr == nil {
90− return true
91− }
92−
93495 // Check platform-specific commands
94496 switch runtime.GOOS {
95497 case "darwin":
internal/cmd/auth_manager.go+3−1
@@ -6,7 +6,7 @@ import ( · 5 unmodified lines
66
77 // newAuthManager creates a new authentication manager instance with all supported
88 // authenticators and a file-based token store. It initializes authenticators for
9−// Gemini, Codex, Claude, and Qwen providers.
9+// Gemini, Codex, Claude, Qwen, IFlow, Antigravity, and GitHub Copilot providers.
1010 //
1111 // Returns:
1212 // - *sdkAuth.Manager: A configured authentication manager instance
@@ -19,6 +19,8 @@ func newAuthManager() *sdkAuth.Manager { · 6 unmodified lines
1919 sdkAuth.NewQwenAuthenticator(),
2020 sdkAuth.NewIFlowAuthenticator(),
2121 sdkAuth.NewAntigravityAuthenticator(),
22+ sdkAuth.NewKiroAuthenticator(),
23+ sdkAuth.NewGitHubCopilotAuthenticator(),
2224 )
2325 return manager
2426 }
internal/cmd/github_copilot_login.go+44−0
@@ -0,0 +1,44 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
8+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
9+ log "github.com/sirupsen/logrus"
10+)
11+
12+// DoGitHubCopilotLogin triggers the OAuth device flow for GitHub Copilot and saves tokens.
13+// It initiates the device flow authentication, displays the user code for the user to enter
14+// at GitHub's verification URL, and waits for authorization before saving the tokens.
15+//
16+// Parameters:
17+// - cfg: The application configuration containing proxy and auth directory settings
18+// - options: Login options including browser behavior settings
19+func DoGitHubCopilotLogin(cfg *config.Config, options *LoginOptions) {
20+ if options == nil {
21+ options = &LoginOptions{}
22+ }
23+
24+ manager := newAuthManager()
25+ authOpts := &sdkAuth.LoginOptions{
26+ NoBrowser: options.NoBrowser,
27+ Metadata: map[string]string{},
28+ Prompt: options.Prompt,
29+ }
30+
31+ record, savedPath, err := manager.Login(context.Background(), "github-copilot", cfg, authOpts)
32+ if err != nil {
33+ log.Errorf("GitHub Copilot authentication failed: %v", err)
34+ return
35+ }
36+
37+ if savedPath != "" {
38+ fmt.Printf("Authentication saved to %s\n", savedPath)
39+ }
40+ if record != nil && record.Label != "" {
41+ fmt.Printf("Authenticated as %s\n", record.Label)
42+ }
43+ fmt.Println("GitHub Copilot authentication successful!")
44+}
internal/cmd/kiro_login.go+208−0
@@ -0,0 +1,208 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
8+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
9+ log "github.com/sirupsen/logrus"
10+)
11+
12+// DoKiroLogin triggers the Kiro authentication flow with Google OAuth.
13+// This is the default login method (same as --kiro-google-login).
14+//
15+// Parameters:
16+// - cfg: The application configuration
17+// - options: Login options including Prompt field
18+func DoKiroLogin(cfg *config.Config, options *LoginOptions) {
19+ // Use Google login as default
20+ DoKiroGoogleLogin(cfg, options)
21+}
22+
23+// DoKiroGoogleLogin triggers Kiro authentication with Google OAuth.
24+// This uses a custom protocol handler (kiro://) to receive the callback.
25+//
26+// Parameters:
27+// - cfg: The application configuration
28+// - options: Login options including prompts
29+func DoKiroGoogleLogin(cfg *config.Config, options *LoginOptions) {
30+ if options == nil {
31+ options = &LoginOptions{}
32+ }
33+
34+ // Note: Kiro defaults to incognito mode for multi-account support.
35+ // Users can override with --no-incognito if they want to use existing browser sessions.
36+
37+ manager := newAuthManager()
38+
39+ // Use KiroAuthenticator with Google login
40+ authenticator := sdkAuth.NewKiroAuthenticator()
41+ record, err := authenticator.LoginWithGoogle(context.Background(), cfg, &sdkAuth.LoginOptions{
42+ NoBrowser: options.NoBrowser,
43+ Metadata: map[string]string{},
44+ Prompt: options.Prompt,
45+ })
46+ if err != nil {
47+ log.Errorf("Kiro Google authentication failed: %v", err)
48+ fmt.Println("\nTroubleshooting:")
49+ fmt.Println("1. Make sure the protocol handler is installed")
50+ fmt.Println("2. Complete the Google login in the browser")
51+ fmt.Println("3. If callback fails, try: --kiro-import (after logging in via Kiro IDE)")
52+ return
53+ }
54+
55+ // Save the auth record
56+ savedPath, err := manager.SaveAuth(record, cfg)
57+ if err != nil {
58+ log.Errorf("Failed to save auth: %v", err)
59+ return
60+ }
61+
62+ if savedPath != "" {
63+ fmt.Printf("Authentication saved to %s\n", savedPath)
64+ }
65+ if record != nil && record.Label != "" {
66+ fmt.Printf("Authenticated as %s\n", record.Label)
67+ }
68+ fmt.Println("Kiro Google authentication successful!")
69+}
70+
71+// DoKiroAWSLogin triggers Kiro authentication with AWS Builder ID.
72+// This uses the device code flow for AWS SSO OIDC authentication.
73+//
74+// Parameters:
75+// - cfg: The application configuration
76+// - options: Login options including prompts
77+func DoKiroAWSLogin(cfg *config.Config, options *LoginOptions) {
78+ if options == nil {
79+ options = &LoginOptions{}
80+ }
81+
82+ // Note: Kiro defaults to incognito mode for multi-account support.
83+ // Users can override with --no-incognito if they want to use existing browser sessions.
84+
85+ manager := newAuthManager()
86+
87+ // Use KiroAuthenticator with AWS Builder ID login (device code flow)
88+ authenticator := sdkAuth.NewKiroAuthenticator()
89+ record, err := authenticator.Login(context.Background(), cfg, &sdkAuth.LoginOptions{
90+ NoBrowser: options.NoBrowser,
91+ Metadata: map[string]string{},
92+ Prompt: options.Prompt,
93+ })
94+ if err != nil {
95+ log.Errorf("Kiro AWS authentication failed: %v", err)
96+ fmt.Println("\nTroubleshooting:")
97+ fmt.Println("1. Make sure you have an AWS Builder ID")
98+ fmt.Println("2. Complete the authorization in the browser")
99+ fmt.Println("3. If callback fails, try: --kiro-import (after logging in via Kiro IDE)")
100+ return
101+ }
102+
103+ // Save the auth record
104+ savedPath, err := manager.SaveAuth(record, cfg)
105+ if err != nil {
106+ log.Errorf("Failed to save auth: %v", err)
107+ return
108+ }
109+
110+ if savedPath != "" {
111+ fmt.Printf("Authentication saved to %s\n", savedPath)
112+ }
113+ if record != nil && record.Label != "" {
114+ fmt.Printf("Authenticated as %s\n", record.Label)
115+ }
116+ fmt.Println("Kiro AWS authentication successful!")
117+}
118+
119+// DoKiroAWSAuthCodeLogin triggers Kiro authentication with AWS Builder ID using authorization code flow.
120+// This provides a better UX than device code flow as it uses automatic browser callback.
121+//
122+// Parameters:
123+// - cfg: The application configuration
124+// - options: Login options including prompts
125+func DoKiroAWSAuthCodeLogin(cfg *config.Config, options *LoginOptions) {
126+ if options == nil {
127+ options = &LoginOptions{}
128+ }
129+
130+ // Note: Kiro defaults to incognito mode for multi-account support.
131+ // Users can override with --no-incognito if they want to use existing browser sessions.
132+
133+ manager := newAuthManager()
134+
135+ // Use KiroAuthenticator with AWS Builder ID login (authorization code flow)
136+ authenticator := sdkAuth.NewKiroAuthenticator()
137+ record, err := authenticator.LoginWithAuthCode(context.Background(), cfg, &sdkAuth.LoginOptions{
138+ NoBrowser: options.NoBrowser,
139+ Metadata: map[string]string{},
140+ Prompt: options.Prompt,
141+ })
142+ if err != nil {
143+ log.Errorf("Kiro AWS authentication (auth code) failed: %v", err)
144+ fmt.Println("\nTroubleshooting:")
145+ fmt.Println("1. Make sure you have an AWS Builder ID")
146+ fmt.Println("2. Complete the authorization in the browser")
147+ fmt.Println("3. If callback fails, try: --kiro-aws-login (device code flow)")
148+ return
149+ }
150+
151+ // Save the auth record
152+ savedPath, err := manager.SaveAuth(record, cfg)
153+ if err != nil {
154+ log.Errorf("Failed to save auth: %v", err)
155+ return
156+ }
157+
158+ if savedPath != "" {
159+ fmt.Printf("Authentication saved to %s\n", savedPath)
160+ }
161+ if record != nil && record.Label != "" {
162+ fmt.Printf("Authenticated as %s\n", record.Label)
163+ }
164+ fmt.Println("Kiro AWS authentication successful!")
165+}
166+
167+// DoKiroImport imports Kiro token from Kiro IDE's token file.
168+// This is useful for users who have already logged in via Kiro IDE
169+// and want to use the same credentials in CLI Proxy API.
170+//
171+// Parameters:
172+// - cfg: The application configuration
173+// - options: Login options (currently unused for import)
174+func DoKiroImport(cfg *config.Config, options *LoginOptions) {
175+ if options == nil {
176+ options = &LoginOptions{}
177+ }
178+
179+ manager := newAuthManager()
180+
181+ // Use ImportFromKiroIDE instead of Login
182+ authenticator := sdkAuth.NewKiroAuthenticator()
183+ record, err := authenticator.ImportFromKiroIDE(context.Background(), cfg)
184+ if err != nil {
185+ log.Errorf("Kiro token import failed: %v", err)
186+ fmt.Println("\nMake sure you have logged in to Kiro IDE first:")
187+ fmt.Println("1. Open Kiro IDE")
188+ fmt.Println("2. Click 'Sign in with Google' (or GitHub)")
189+ fmt.Println("3. Complete the login process")
190+ fmt.Println("4. Run this command again")
191+ return
192+ }
193+
194+ // Save the imported auth record
195+ savedPath, err := manager.SaveAuth(record, cfg)
196+ if err != nil {
197+ log.Errorf("Failed to save auth: %v", err)
198+ return
199+ }
200+
201+ if savedPath != "" {
202+ fmt.Printf("Authentication saved to %s\n", savedPath)
203+ }
204+ if record != nil && record.Label != "" {
205+ fmt.Printf("Imported as %s\n", record.Label)
206+ }
207+ fmt.Println("Kiro token import successful!")
208+}
internal/config/config.go+62−0
@@ -72,6 +72,13 @@ type Config struct { · 71 unmodified lines
7272 // GeminiKey defines Gemini API key configurations with optional routing overrides.
7373 GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
7474
75+ // KiroKey defines a list of Kiro (AWS CodeWhisperer) configurations.
76+ KiroKey []KiroKey `yaml:"kiro" json:"kiro"`
77+
78+ // KiroPreferredEndpoint sets the global default preferred endpoint for all Kiro providers.
79+ // Values: "ide" (default, CodeWhisperer) or "cli" (Amazon Q).
80+ KiroPreferredEndpoint string `yaml:"kiro-preferred-endpoint" json:"kiro-preferred-endpoint"`
81+
7582 // Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
7683 CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
7784
@@ -102,6 +109,11 @@ type Config struct { · 24 unmodified lines
102109 // Payload defines default and override rules for provider payload parameters.
103110 Payload PayloadConfig `yaml:"payload" json:"payload"`
104111
112+ // IncognitoBrowser enables opening OAuth URLs in incognito/private browsing mode.
113+ // This is useful when you want to login with a different account without logging out
114+ // from your current session. Default: false.
115+ IncognitoBrowser bool `yaml:"incognito-browser" json:"incognito-browser"`
116+
105117 legacyMigrationPending bool `yaml:"-" json:"-"`
106118 }
107119
@@ -346,6 +358,35 @@ type GeminiModel struct { · 238 unmodified lines
346358 func (m GeminiModel) GetName() string { return m.Name }
347359 func (m GeminiModel) GetAlias() string { return m.Alias }
348360
361+// KiroKey represents the configuration for Kiro (AWS CodeWhisperer) authentication.
362+type KiroKey struct {
363+ // TokenFile is the path to the Kiro token file (default: ~/.aws/sso/cache/kiro-auth-token.json)
364+ TokenFile string `yaml:"token-file,omitempty" json:"token-file,omitempty"`
365+
366+ // AccessToken is the OAuth access token for direct configuration.
367+ AccessToken string `yaml:"access-token,omitempty" json:"access-token,omitempty"`
368+
369+ // RefreshToken is the OAuth refresh token for token renewal.
370+ RefreshToken string `yaml:"refresh-token,omitempty" json:"refresh-token,omitempty"`
371+
372+ // ProfileArn is the AWS CodeWhisperer profile ARN.
373+ ProfileArn string `yaml:"profile-arn,omitempty" json:"profile-arn,omitempty"`
374+
375+ // Region is the AWS region (default: us-east-1).
376+ Region string `yaml:"region,omitempty" json:"region,omitempty"`
377+
378+ // ProxyURL optionally overrides the global proxy for this configuration.
379+ ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
380+
381+ // AgentTaskType sets the Kiro API task type. Known values: "vibe", "dev", "chat".
382+ // Leave empty to let API use defaults. Different values may inject different system prompts.
383+ AgentTaskType string `yaml:"agent-task-type,omitempty" json:"agent-task-type,omitempty"`
384+
385+ // PreferredEndpoint sets the preferred Kiro API endpoint/quota.
386+ // Values: "codewhisperer" (default, IDE quota) or "amazonq" (CLI quota).
387+ PreferredEndpoint string `yaml:"preferred-endpoint,omitempty" json:"preferred-endpoint,omitempty"`
388+}
389+
349390 // OpenAICompatibility represents the configuration for OpenAI API compatibility
350391 // with external providers, allowing model aliases to be routed through OpenAI API format.
351392 type OpenAICompatibility struct {
@@ -432,6 +473,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { · 80 unmodified lines
432473 cfg.DisableCooling = false
433474 cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
434475 cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
476+ cfg.IncognitoBrowser = false // Default to normal browser (AWS uses incognito by force)
435477 if err = yaml.Unmarshal(data, &cfg); err != nil {
436478 if optional {
437479 // In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
@@ -491,6 +533,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { · 53 unmodified lines
491533 // Sanitize Claude key headers
492534 cfg.SanitizeClaudeKeys()
493535
536+ // Sanitize Kiro keys: trim whitespace from credential fields
537+ cfg.SanitizeKiroKeys()
538+
494539 // Sanitize OpenAI compatibility providers: drop entries without base-url
495540 cfg.SanitizeOpenAICompatibility()
496541
@@ -617,6 +662,23 @@ func (cfg *Config) SanitizeClaudeKeys() { · 120 unmodified lines
617662 }
618663 }
619664
665+// SanitizeKiroKeys trims whitespace from Kiro credential fields.
666+func (cfg *Config) SanitizeKiroKeys() {
667+ if cfg == nil || len(cfg.KiroKey) == 0 {
668+ return
669+ }
670+ for i := range cfg.KiroKey {
671+ entry := &cfg.KiroKey[i]
672+ entry.TokenFile = strings.TrimSpace(entry.TokenFile)
673+ entry.AccessToken = strings.TrimSpace(entry.AccessToken)
674+ entry.RefreshToken = strings.TrimSpace(entry.RefreshToken)
675+ entry.ProfileArn = strings.TrimSpace(entry.ProfileArn)
676+ entry.Region = strings.TrimSpace(entry.Region)
677+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
678+ entry.PreferredEndpoint = strings.TrimSpace(entry.PreferredEndpoint)
679+ }
680+}
681+
620682 // SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
621683 func (cfg *Config) SanitizeGeminiKeys() {
622684 if cfg == nil {
internal/constant/constant.go+3−0
@@ -24,4 +24,7 @@ const ( · 23 unmodified lines
2424
2525 // Antigravity represents the Antigravity response format identifier.
2626 Antigravity = "antigravity"
27+
28+ // Kiro represents the AWS CodeWhisperer (Kiro) provider identifier.
29+ Kiro = "kiro"
2730 )
internal/logging/global_logger.go+1−0
@@ -68,6 +68,7 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) { · 67 unmodified lines
6868 func SetupBaseLogger() {
6969 setupOnce.Do(func() {
7070 log.SetOutput(os.Stdout)
71+ log.SetLevel(log.InfoLevel)
7172 log.SetReportCaller(true)
7273 log.SetFormatter(&LogFormatter{})
7374
internal/registry/model_definitions.go+361−0
@@ -807,3 +807,364 @@ func LookupStaticModelInfo(modelID string) *ModelInfo { · 806 unmodified lines
807807 }
808808 return nil
809809 }
810+
811+// GetGitHubCopilotModels returns the available models for GitHub Copilot.
812+// These models are available through the GitHub Copilot API at api.githubcopilot.com.
813+func GetGitHubCopilotModels() []*ModelInfo {
814+ now := int64(1732752000) // 2024-11-27
815+ return []*ModelInfo{
816+ {
817+ ID: "gpt-4.1",
818+ Object: "model",
819+ Created: now,
820+ OwnedBy: "github-copilot",
821+ Type: "github-copilot",
822+ DisplayName: "GPT-4.1",
823+ Description: "OpenAI GPT-4.1 via GitHub Copilot",
824+ ContextLength: 128000,
825+ MaxCompletionTokens: 16384,
826+ },
827+ {
828+ ID: "gpt-5",
829+ Object: "model",
830+ Created: now,
831+ OwnedBy: "github-copilot",
832+ Type: "github-copilot",
833+ DisplayName: "GPT-5",
834+ Description: "OpenAI GPT-5 via GitHub Copilot",
835+ ContextLength: 200000,
836+ MaxCompletionTokens: 32768,
837+ },
838+ {
839+ ID: "gpt-5-mini",
840+ Object: "model",
841+ Created: now,
842+ OwnedBy: "github-copilot",
843+ Type: "github-copilot",
844+ DisplayName: "GPT-5 Mini",
845+ Description: "OpenAI GPT-5 Mini via GitHub Copilot",
846+ ContextLength: 128000,
847+ MaxCompletionTokens: 16384,
848+ },
849+ {
850+ ID: "gpt-5-codex",
851+ Object: "model",
852+ Created: now,
853+ OwnedBy: "github-copilot",
854+ Type: "github-copilot",
855+ DisplayName: "GPT-5 Codex",
856+ Description: "OpenAI GPT-5 Codex via GitHub Copilot",
857+ ContextLength: 200000,
858+ MaxCompletionTokens: 32768,
859+ },
860+ {
861+ ID: "gpt-5.1",
862+ Object: "model",
863+ Created: now,
864+ OwnedBy: "github-copilot",
865+ Type: "github-copilot",
866+ DisplayName: "GPT-5.1",
867+ Description: "OpenAI GPT-5.1 via GitHub Copilot",
868+ ContextLength: 200000,
869+ MaxCompletionTokens: 32768,
870+ },
871+ {
872+ ID: "gpt-5.1-codex",
873+ Object: "model",
874+ Created: now,
875+ OwnedBy: "github-copilot",
876+ Type: "github-copilot",
877+ DisplayName: "GPT-5.1 Codex",
878+ Description: "OpenAI GPT-5.1 Codex via GitHub Copilot",
879+ ContextLength: 200000,
880+ MaxCompletionTokens: 32768,
881+ },
882+ {
883+ ID: "gpt-5.1-codex-mini",
884+ Object: "model",
885+ Created: now,
886+ OwnedBy: "github-copilot",
887+ Type: "github-copilot",
888+ DisplayName: "GPT-5.1 Codex Mini",
889+ Description: "OpenAI GPT-5.1 Codex Mini via GitHub Copilot",
890+ ContextLength: 128000,
891+ MaxCompletionTokens: 16384,
892+ },
893+ {
894+ ID: "gpt-5.2",
895+ Object: "model",
896+ Created: now,
897+ OwnedBy: "github-copilot",
898+ Type: "github-copilot",
899+ DisplayName: "GPT-5.2",
900+ Description: "OpenAI GPT-5.2 via GitHub Copilot",
901+ ContextLength: 200000,
902+ MaxCompletionTokens: 32768,
903+ },
904+ {
905+ ID: "claude-haiku-4.5",
906+ Object: "model",
907+ Created: now,
908+ OwnedBy: "github-copilot",
909+ Type: "github-copilot",
910+ DisplayName: "Claude Haiku 4.5",
911+ Description: "Anthropic Claude Haiku 4.5 via GitHub Copilot",
912+ ContextLength: 200000,
913+ MaxCompletionTokens: 64000,
914+ },
915+ {
916+ ID: "claude-opus-4.1",
917+ Object: "model",
918+ Created: now,
919+ OwnedBy: "github-copilot",
920+ Type: "github-copilot",
921+ DisplayName: "Claude Opus 4.1",
922+ Description: "Anthropic Claude Opus 4.1 via GitHub Copilot",
923+ ContextLength: 200000,
924+ MaxCompletionTokens: 32000,
925+ },
926+ {
927+ ID: "claude-opus-4.5",
928+ Object: "model",
929+ Created: now,
930+ OwnedBy: "github-copilot",
931+ Type: "github-copilot",
932+ DisplayName: "Claude Opus 4.5",
933+ Description: "Anthropic Claude Opus 4.5 via GitHub Copilot",
934+ ContextLength: 200000,
935+ MaxCompletionTokens: 64000,
936+ },
937+ {
938+ ID: "claude-sonnet-4",
939+ Object: "model",
940+ Created: now,
941+ OwnedBy: "github-copilot",
942+ Type: "github-copilot",
943+ DisplayName: "Claude Sonnet 4",
944+ Description: "Anthropic Claude Sonnet 4 via GitHub Copilot",
945+ ContextLength: 200000,
946+ MaxCompletionTokens: 64000,
947+ },
948+ {
949+ ID: "claude-sonnet-4.5",
950+ Object: "model",
951+ Created: now,
952+ OwnedBy: "github-copilot",
953+ Type: "github-copilot",
954+ DisplayName: "Claude Sonnet 4.5",
955+ Description: "Anthropic Claude Sonnet 4.5 via GitHub Copilot",
956+ ContextLength: 200000,
957+ MaxCompletionTokens: 64000,
958+ },
959+ {
960+ ID: "gemini-2.5-pro",
961+ Object: "model",
962+ Created: now,
963+ OwnedBy: "github-copilot",
964+ Type: "github-copilot",
965+ DisplayName: "Gemini 2.5 Pro",
966+ Description: "Google Gemini 2.5 Pro via GitHub Copilot",
967+ ContextLength: 1048576,
968+ MaxCompletionTokens: 65536,
969+ },
970+ {
971+ ID: "gemini-3-pro",
972+ Object: "model",
973+ Created: now,
974+ OwnedBy: "github-copilot",
975+ Type: "github-copilot",
976+ DisplayName: "Gemini 3 Pro",
977+ Description: "Google Gemini 3 Pro via GitHub Copilot",
978+ ContextLength: 1048576,
979+ MaxCompletionTokens: 65536,
980+ },
981+ {
982+ ID: "grok-code-fast-1",
983+ Object: "model",
984+ Created: now,
985+ OwnedBy: "github-copilot",
986+ Type: "github-copilot",
987+ DisplayName: "Grok Code Fast 1",
988+ Description: "xAI Grok Code Fast 1 via GitHub Copilot",
989+ ContextLength: 128000,
990+ MaxCompletionTokens: 16384,
991+ },
992+ {
993+ ID: "raptor-mini",
994+ Object: "model",
995+ Created: now,
996+ OwnedBy: "github-copilot",
997+ Type: "github-copilot",
998+ DisplayName: "Raptor Mini",
999+ Description: "Raptor Mini via GitHub Copilot",
1000+ ContextLength: 128000,
1001+ MaxCompletionTokens: 16384,
1002+ },
1003+ }
1004+}
1005+
1006+// GetKiroModels returns the Kiro (AWS CodeWhisperer) model definitions
1007+func GetKiroModels() []*ModelInfo {
1008+ return []*ModelInfo{
1009+ // --- Base Models ---
1010+ {
1011+ ID: "kiro-claude-opus-4-5",
1012+ Object: "model",
1013+ Created: 1732752000,
1014+ OwnedBy: "aws",
1015+ Type: "kiro",
1016+ DisplayName: "Kiro Claude Opus 4.5",
1017+ Description: "Claude Opus 4.5 via Kiro (2.2x credit)",
1018+ ContextLength: 200000,
1019+ MaxCompletionTokens: 64000,
1020+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1021+ },
1022+ {
1023+ ID: "kiro-claude-sonnet-4-5",
1024+ Object: "model",
1025+ Created: 1732752000,
1026+ OwnedBy: "aws",
1027+ Type: "kiro",
1028+ DisplayName: "Kiro Claude Sonnet 4.5",
1029+ Description: "Claude Sonnet 4.5 via Kiro (1.3x credit)",
1030+ ContextLength: 200000,
1031+ MaxCompletionTokens: 64000,
1032+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1033+ },
1034+ {
1035+ ID: "kiro-claude-sonnet-4",
1036+ Object: "model",
1037+ Created: 1732752000,
1038+ OwnedBy: "aws",
1039+ Type: "kiro",
1040+ DisplayName: "Kiro Claude Sonnet 4",
1041+ Description: "Claude Sonnet 4 via Kiro (1.3x credit)",
1042+ ContextLength: 200000,
1043+ MaxCompletionTokens: 64000,
1044+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1045+ },
1046+ {
1047+ ID: "kiro-claude-haiku-4-5",
1048+ Object: "model",
1049+ Created: 1732752000,
1050+ OwnedBy: "aws",
1051+ Type: "kiro",
1052+ DisplayName: "Kiro Claude Haiku 4.5",
1053+ Description: "Claude Haiku 4.5 via Kiro (0.4x credit)",
1054+ ContextLength: 200000,
1055+ MaxCompletionTokens: 64000,
1056+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1057+ },
1058+ // --- Agentic Variants (Optimized for coding agents with chunked writes) ---
1059+ {
1060+ ID: "kiro-claude-opus-4-5-agentic",
1061+ Object: "model",
1062+ Created: 1732752000,
1063+ OwnedBy: "aws",
1064+ Type: "kiro",
1065+ DisplayName: "Kiro Claude Opus 4.5 (Agentic)",
1066+ Description: "Claude Opus 4.5 optimized for coding agents (chunked writes)",
1067+ ContextLength: 200000,
1068+ MaxCompletionTokens: 64000,
1069+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1070+ },
1071+ {
1072+ ID: "kiro-claude-sonnet-4-5-agentic",
1073+ Object: "model",
1074+ Created: 1732752000,
1075+ OwnedBy: "aws",
1076+ Type: "kiro",
1077+ DisplayName: "Kiro Claude Sonnet 4.5 (Agentic)",
1078+ Description: "Claude Sonnet 4.5 optimized for coding agents (chunked writes)",
1079+ ContextLength: 200000,
1080+ MaxCompletionTokens: 64000,
1081+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1082+ },
1083+ {
1084+ ID: "kiro-claude-sonnet-4-agentic",
1085+ Object: "model",
1086+ Created: 1732752000,
1087+ OwnedBy: "aws",
1088+ Type: "kiro",
1089+ DisplayName: "Kiro Claude Sonnet 4 (Agentic)",
1090+ Description: "Claude Sonnet 4 optimized for coding agents (chunked writes)",
1091+ ContextLength: 200000,
1092+ MaxCompletionTokens: 64000,
1093+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1094+ },
1095+ {
1096+ ID: "kiro-claude-haiku-4-5-agentic",
1097+ Object: "model",
1098+ Created: 1732752000,
1099+ OwnedBy: "aws",
1100+ Type: "kiro",
1101+ DisplayName: "Kiro Claude Haiku 4.5 (Agentic)",
1102+ Description: "Claude Haiku 4.5 optimized for coding agents (chunked writes)",
1103+ ContextLength: 200000,
1104+ MaxCompletionTokens: 64000,
1105+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1106+ },
1107+ }
1108+}
1109+
1110+// GetAmazonQModels returns the Amazon Q (AWS CodeWhisperer) model definitions.
1111+// These models use the same API as Kiro and share the same executor.
1112+func GetAmazonQModels() []*ModelInfo {
1113+ return []*ModelInfo{
1114+ {
1115+ ID: "amazonq-auto",
1116+ Object: "model",
1117+ Created: 1732752000,
1118+ OwnedBy: "aws",
1119+ Type: "kiro", // Uses Kiro executor - same API
1120+ DisplayName: "Amazon Q Auto",
1121+ Description: "Automatic model selection by Amazon Q",
1122+ ContextLength: 200000,
1123+ MaxCompletionTokens: 64000,
1124+ },
1125+ {
1126+ ID: "amazonq-claude-opus-4.5",
1127+ Object: "model",
1128+ Created: 1732752000,
1129+ OwnedBy: "aws",
1130+ Type: "kiro",
1131+ DisplayName: "Amazon Q Claude Opus 4.5",
1132+ Description: "Claude Opus 4.5 via Amazon Q (2.2x credit)",
1133+ ContextLength: 200000,
1134+ MaxCompletionTokens: 64000,
1135+ },
1136+ {
1137+ ID: "amazonq-claude-sonnet-4.5",
1138+ Object: "model",
1139+ Created: 1732752000,
1140+ OwnedBy: "aws",
1141+ Type: "kiro",
1142+ DisplayName: "Amazon Q Claude Sonnet 4.5",
1143+ Description: "Claude Sonnet 4.5 via Amazon Q (1.3x credit)",
1144+ ContextLength: 200000,
1145+ MaxCompletionTokens: 64000,
1146+ },
1147+ {
1148+ ID: "amazonq-claude-sonnet-4",
1149+ Object: "model",
1150+ Created: 1732752000,
1151+ OwnedBy: "aws",
1152+ Type: "kiro",
1153+ DisplayName: "Amazon Q Claude Sonnet 4",
1154+ Description: "Claude Sonnet 4 via Amazon Q (1.3x credit)",
1155+ ContextLength: 200000,
1156+ MaxCompletionTokens: 64000,
1157+ },
1158+ {
1159+ ID: "amazonq-claude-haiku-4.5",
1160+ Object: "model",
1161+ Created: 1732752000,
1162+ OwnedBy: "aws",
1163+ Type: "kiro",
1164+ DisplayName: "Amazon Q Claude Haiku 4.5",
1165+ Description: "Claude Haiku 4.5 via Amazon Q (0.4x credit)",
1166+ ContextLength: 200000,
1167+ MaxCompletionTokens: 64000,
1168+ },
1169+ }
1170+}
internal/registry/model_registry.go+15−1
@@ -766,7 +766,8 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string) · 765 unmodified lines
766766 }
767767 return result
768768
769− case "claude":
769+ case "claude", "kiro", "antigravity":
770+ // Claude, Kiro, and Antigravity all use Claude-compatible format for Claude Code client
770771 result := map[string]any{
771772 "id": model.ID,
772773 "object": "model",
@@ -781,6 +782,19 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string) · 8 unmodified lines
781782 if model.DisplayName != "" {
782783 result["display_name"] = model.DisplayName
783784 }
785+ // Add thinking support for Claude Code client
786+ // Claude Code checks for "thinking" field (simple boolean) to enable tab toggle
787+ // Also add "extended_thinking" for detailed budget info
788+ if model.Thinking != nil {
789+ result["thinking"] = true
790+ result["extended_thinking"] = map[string]any{
791+ "supported": true,
792+ "min": model.Thinking.Min,
793+ "max": model.Thinking.Max,
794+ "zero_allowed": model.Thinking.ZeroAllowed,
795+ "dynamic_allowed": model.Thinking.DynamicAllowed,
796+ }
797+ }
784798 return result
785799
786800 case "gemini":
internal/runtime/executor/cache_helpers.go+30−2
@@ -1,10 +1,38 @@
11 package executor
22
3−import "time"
3+import (
4+ "sync"
5+ "time"
6+)
47
58 type codexCache struct {
69 ID string
710 Expire time.Time
811 }
912
10−var codexCacheMap = map[string]codexCache{}
13+var (
14+ codexCacheMap = map[string]codexCache{}
15+ codexCacheMutex sync.RWMutex
16+)
17+
18+// getCodexCache safely retrieves a cache entry
19+func getCodexCache(key string) (codexCache, bool) {
20+ codexCacheMutex.RLock()
21+ defer codexCacheMutex.RUnlock()
22+ cache, ok := codexCacheMap[key]
23+ return cache, ok
24+}
25+
26+// setCodexCache safely sets a cache entry
27+func setCodexCache(key string, cache codexCache) {
28+ codexCacheMutex.Lock()
29+ defer codexCacheMutex.Unlock()
30+ codexCacheMap[key] = cache
31+}
32+
33+// deleteCodexCache safely deletes a cache entry
34+func deleteCodexCache(key string) {
35+ codexCacheMutex.Lock()
36+ defer codexCacheMutex.Unlock()
37+ delete(codexCacheMap, key)
38+}
internal/runtime/executor/codex_executor.go+2−2
@@ -449,12 +449,12 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form · 448 unmodified lines
449449 if userIDResult.Exists() {
450450 var hasKey bool
451451 key := fmt.Sprintf("%s-%s", req.Model, userIDResult.String())
452− if cache, hasKey = codexCacheMap[key]; !hasKey || cache.Expire.Before(time.Now()) {
452+ if cache, hasKey = getCodexCache(key); !hasKey || cache.Expire.Before(time.Now()) {
453453 cache = codexCache{
454454 ID: uuid.New().String(),
455455 Expire: time.Now().Add(1 * time.Hour),
456456 }
457− codexCacheMap[key] = cache
457+ setCodexCache(key, cache)
458458 }
459459 }
460460 } else if from == "openai-response" {
internal/runtime/executor/github_copilot_executor.go+361−0
@@ -0,0 +1,361 @@
1+package executor
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "sync"
11+ "time"
12+
13+ "github.com/google/uuid"
14+ copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
15+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
16+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
17+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
18+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
19+ log "github.com/sirupsen/logrus"
20+ "github.com/tidwall/sjson"
21+)
22+
23+const (
24+ githubCopilotBaseURL = "https://api.githubcopilot.com"
25+ githubCopilotChatPath = "/chat/completions"
26+ githubCopilotAuthType = "github-copilot"
27+ githubCopilotTokenCacheTTL = 25 * time.Minute
28+ // tokenExpiryBuffer is the time before expiry when we should refresh the token.
29+ tokenExpiryBuffer = 5 * time.Minute
30+ // maxScannerBufferSize is the maximum buffer size for SSE scanning (20MB).
31+ maxScannerBufferSize = 20_971_520
32+
33+ // Copilot API header values.
34+ copilotUserAgent = "GithubCopilot/1.0"
35+ copilotEditorVersion = "vscode/1.100.0"
36+ copilotPluginVersion = "copilot/1.300.0"
37+ copilotIntegrationID = "vscode-chat"
38+ copilotOpenAIIntent = "conversation-panel"
39+)
40+
41+// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
42+type GitHubCopilotExecutor struct {
43+ cfg *config.Config
44+ mu sync.RWMutex
45+ cache map[string]*cachedAPIToken
46+}
47+
48+// cachedAPIToken stores a cached Copilot API token with its expiry.
49+type cachedAPIToken struct {
50+ token string
51+ expiresAt time.Time
52+}
53+
54+// NewGitHubCopilotExecutor constructs a new executor instance.
55+func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
56+ return &GitHubCopilotExecutor{
57+ cfg: cfg,
58+ cache: make(map[string]*cachedAPIToken),
59+ }
60+}
61+
62+// Identifier implements ProviderExecutor.
63+func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
64+
65+// PrepareRequest implements ProviderExecutor.
66+func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
67+ return nil
68+}
69+
70+// Execute handles non-streaming requests to GitHub Copilot.
71+func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
72+ apiToken, errToken := e.ensureAPIToken(ctx, auth)
73+ if errToken != nil {
74+ return resp, errToken
75+ }
76+
77+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
78+ defer reporter.trackFailure(ctx, &err)
79+
80+ from := opts.SourceFormat
81+ to := sdktranslator.FromString("openai")
82+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
83+ body = e.normalizeModel(req.Model, body)
84+ body = applyPayloadConfig(e.cfg, req.Model, body)
85+ body, _ = sjson.SetBytes(body, "stream", false)
86+
87+ url := githubCopilotBaseURL + githubCopilotChatPath
88+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
89+ if err != nil {
90+ return resp, err
91+ }
92+ e.applyHeaders(httpReq, apiToken)
93+
94+ var authID, authLabel, authType, authValue string
95+ if auth != nil {
96+ authID = auth.ID
97+ authLabel = auth.Label
98+ authType, authValue = auth.AccountInfo()
99+ }
100+ recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
101+ URL: url,
102+ Method: http.MethodPost,
103+ Headers: httpReq.Header.Clone(),
104+ Body: body,
105+ Provider: e.Identifier(),
106+ AuthID: authID,
107+ AuthLabel: authLabel,
108+ AuthType: authType,
109+ AuthValue: authValue,
110+ })
111+
112+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
113+ httpResp, err := httpClient.Do(httpReq)
114+ if err != nil {
115+ recordAPIResponseError(ctx, e.cfg, err)
116+ return resp, err
117+ }
118+ defer func() {
119+ if errClose := httpResp.Body.Close(); errClose != nil {
120+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
121+ }
122+ }()
123+
124+ recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
125+
126+ if !isHTTPSuccess(httpResp.StatusCode) {
127+ data, _ := io.ReadAll(httpResp.Body)
128+ appendAPIResponseChunk(ctx, e.cfg, data)
129+ log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
130+ err = statusErr{code: httpResp.StatusCode, msg: string(data)}
131+ return resp, err
132+ }
133+
134+ data, err := io.ReadAll(httpResp.Body)
135+ if err != nil {
136+ recordAPIResponseError(ctx, e.cfg, err)
137+ return resp, err
138+ }
139+ appendAPIResponseChunk(ctx, e.cfg, data)
140+
141+ detail := parseOpenAIUsage(data)
142+ if detail.TotalTokens > 0 {
143+ reporter.publish(ctx, detail)
144+ }
145+
146+ var param any
147+ converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
148+ resp = cliproxyexecutor.Response{Payload: []byte(converted)}
149+ reporter.ensurePublished(ctx)
150+ return resp, nil
151+}
152+
153+// ExecuteStream handles streaming requests to GitHub Copilot.
154+func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
155+ apiToken, errToken := e.ensureAPIToken(ctx, auth)
156+ if errToken != nil {
157+ return nil, errToken
158+ }
159+
160+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
161+ defer reporter.trackFailure(ctx, &err)
162+
163+ from := opts.SourceFormat
164+ to := sdktranslator.FromString("openai")
165+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
166+ body = e.normalizeModel(req.Model, body)
167+ body = applyPayloadConfig(e.cfg, req.Model, body)
168+ body, _ = sjson.SetBytes(body, "stream", true)
169+ // Enable stream options for usage stats in stream
170+ body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
171+
172+ url := githubCopilotBaseURL + githubCopilotChatPath
173+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
174+ if err != nil {
175+ return nil, err
176+ }
177+ e.applyHeaders(httpReq, apiToken)
178+
179+ var authID, authLabel, authType, authValue string
180+ if auth != nil {
181+ authID = auth.ID
182+ authLabel = auth.Label
183+ authType, authValue = auth.AccountInfo()
184+ }
185+ recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
186+ URL: url,
187+ Method: http.MethodPost,
188+ Headers: httpReq.Header.Clone(),
189+ Body: body,
190+ Provider: e.Identifier(),
191+ AuthID: authID,
192+ AuthLabel: authLabel,
193+ AuthType: authType,
194+ AuthValue: authValue,
195+ })
196+
197+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
198+ httpResp, err := httpClient.Do(httpReq)
199+ if err != nil {
200+ recordAPIResponseError(ctx, e.cfg, err)
201+ return nil, err
202+ }
203+
204+ recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
205+
206+ if !isHTTPSuccess(httpResp.StatusCode) {
207+ data, readErr := io.ReadAll(httpResp.Body)
208+ if errClose := httpResp.Body.Close(); errClose != nil {
209+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
210+ }
211+ if readErr != nil {
212+ recordAPIResponseError(ctx, e.cfg, readErr)
213+ return nil, readErr
214+ }
215+ appendAPIResponseChunk(ctx, e.cfg, data)
216+ log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
217+ err = statusErr{code: httpResp.StatusCode, msg: string(data)}
218+ return nil, err
219+ }
220+
221+ out := make(chan cliproxyexecutor.StreamChunk)
222+ stream = out
223+
224+ go func() {
225+ defer close(out)
226+ defer func() {
227+ if errClose := httpResp.Body.Close(); errClose != nil {
228+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
229+ }
230+ }()
231+
232+ scanner := bufio.NewScanner(httpResp.Body)
233+ scanner.Buffer(nil, maxScannerBufferSize)
234+ var param any
235+
236+ for scanner.Scan() {
237+ line := scanner.Bytes()
238+ appendAPIResponseChunk(ctx, e.cfg, line)
239+
240+ // Parse SSE data
241+ if bytes.HasPrefix(line, dataTag) {
242+ data := bytes.TrimSpace(line[5:])
243+ if bytes.Equal(data, []byte("[DONE]")) {
244+ continue
245+ }
246+ if detail, ok := parseOpenAIStreamUsage(line); ok {
247+ reporter.publish(ctx, detail)
248+ }
249+ }
250+
251+ chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
252+ for i := range chunks {
253+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
254+ }
255+ }
256+
257+ if errScan := scanner.Err(); errScan != nil {
258+ recordAPIResponseError(ctx, e.cfg, errScan)
259+ reporter.publishFailure(ctx)
260+ out <- cliproxyexecutor.StreamChunk{Err: errScan}
261+ } else {
262+ reporter.ensurePublished(ctx)
263+ }
264+ }()
265+
266+ return stream, nil
267+}
268+
269+// CountTokens is not supported for GitHub Copilot.
270+func (e *GitHubCopilotExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
271+ return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported for github-copilot"}
272+}
273+
274+// Refresh validates the GitHub token is still working.
275+// GitHub OAuth tokens don't expire traditionally, so we just validate.
276+func (e *GitHubCopilotExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
277+ if auth == nil {
278+ return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
279+ }
280+
281+ // Get the GitHub access token
282+ accessToken := metaStringValue(auth.Metadata, "access_token")
283+ if accessToken == "" {
284+ return auth, nil
285+ }
286+
287+ // Validate the token can still get a Copilot API token
288+ copilotAuth := copilotauth.NewCopilotAuth(e.cfg)
289+ _, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
290+ if err != nil {
291+ return nil, statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("github-copilot token validation failed: %v", err)}
292+ }
293+
294+ return auth, nil
295+}
296+
297+// ensureAPIToken gets or refreshes the Copilot API token.
298+func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
299+ if auth == nil {
300+ return "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
301+ }
302+
303+ // Get the GitHub access token
304+ accessToken := metaStringValue(auth.Metadata, "access_token")
305+ if accessToken == "" {
306+ return "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"}
307+ }
308+
309+ // Check for cached API token using thread-safe access
310+ e.mu.RLock()
311+ if cached, ok := e.cache[accessToken]; ok && cached.expiresAt.After(time.Now().Add(tokenExpiryBuffer)) {
312+ e.mu.RUnlock()
313+ return cached.token, nil
314+ }
315+ e.mu.RUnlock()
316+
317+ // Get a new Copilot API token
318+ copilotAuth := copilotauth.NewCopilotAuth(e.cfg)
319+ apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
320+ if err != nil {
321+ return "", statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("failed to get copilot api token: %v", err)}
322+ }
323+
324+ // Cache the token with thread-safe access
325+ expiresAt := time.Now().Add(githubCopilotTokenCacheTTL)
326+ if apiToken.ExpiresAt > 0 {
327+ expiresAt = time.Unix(apiToken.ExpiresAt, 0)
328+ }
329+ e.mu.Lock()
330+ e.cache[accessToken] = &cachedAPIToken{
331+ token: apiToken.Token,
332+ expiresAt: expiresAt,
333+ }
334+ e.mu.Unlock()
335+
336+ return apiToken.Token, nil
337+}
338+
339+// applyHeaders sets the required headers for GitHub Copilot API requests.
340+func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {
341+ r.Header.Set("Content-Type", "application/json")
342+ r.Header.Set("Authorization", "Bearer "+apiToken)
343+ r.Header.Set("Accept", "application/json")
344+ r.Header.Set("User-Agent", copilotUserAgent)
345+ r.Header.Set("Editor-Version", copilotEditorVersion)
346+ r.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
347+ r.Header.Set("Openai-Intent", copilotOpenAIIntent)
348+ r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
349+ r.Header.Set("X-Request-Id", uuid.NewString())
350+}
351+
352+// normalizeModel is a no-op as GitHub Copilot accepts model names directly.
353+// Model mapping should be done at the registry level if needed.
354+func (e *GitHubCopilotExecutor) normalizeModel(_ string, body []byte) []byte {
355+ return body
356+}
357+
358+// isHTTPSuccess checks if the status code indicates success (2xx).
359+func isHTTPSuccess(statusCode int) bool {
360+ return statusCode >= 200 && statusCode < 300
361+}
internal/runtime/executor/kiro_executor.go+3302−0
@@ -0,0 +1,3302 @@
1+package executor
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/base64"
8+ "encoding/binary"
9+ "encoding/json"
10+ "fmt"
11+ "io"
12+ "net/http"
13+ "os"
14+ "path/filepath"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/google/uuid"
20+ kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
21+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
22+ kiroclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/claude"
23+ kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
24+ kiroopenai "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/openai"
25+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
26+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
27+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
28+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
29+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
30+ log "github.com/sirupsen/logrus"
31+
32+)
33+
34+const (
35+ // Kiro API common constants
36+ kiroContentType = "application/x-amz-json-1.0"
37+ kiroAcceptStream = "*/*"
38+
39+ // Event Stream frame size constants for boundary protection
40+ // AWS Event Stream binary format: prelude (12 bytes) + headers + payload + message_crc (4 bytes)
41+ // Prelude consists of: total_length (4) + headers_length (4) + prelude_crc (4)
42+ minEventStreamFrameSize = 16 // Minimum: 4(total_len) + 4(headers_len) + 4(prelude_crc) + 4(message_crc)
43+ maxEventStreamMsgSize = 10 << 20 // Maximum message length: 10MB
44+
45+ // Event Stream error type constants
46+ ErrStreamFatal = "fatal" // Connection/authentication errors, not recoverable
47+ ErrStreamMalformed = "malformed" // Format errors, data cannot be parsed
48+ // kiroUserAgent matches amq2api format for User-Agent header (Amazon Q CLI style)
49+ kiroUserAgent = "aws-sdk-rust/1.3.9 os/macos lang/rust/1.87.0"
50+ // kiroFullUserAgent is the complete x-amz-user-agent header matching amq2api (Amazon Q CLI style)
51+ kiroFullUserAgent = "aws-sdk-rust/1.3.9 ua/2.1 api/ssooidc/1.88.0 os/macos lang/rust/1.87.0 m/E app/AmazonQ-For-CLI"
52+
53+ // Kiro IDE style headers (from kiro2api - for IDC auth)
54+ kiroIDEUserAgent = "aws-sdk-js/1.0.18 ua/2.1 os/darwin#25.0.0 lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.18 m/E KiroIDE-0.2.13-66c23a8c5d15afabec89ef9954ef52a119f10d369df04d548fc6c1eac694b0d1"
55+ kiroIDEAmzUserAgent = "aws-sdk-js/1.0.18 KiroIDE-0.2.13-66c23a8c5d15afabec89ef9954ef52a119f10d369df04d548fc6c1eac694b0d1"
56+ kiroIDEAgentModeSpec = "spec"
57+)
58+
59+// Real-time usage estimation configuration
60+// These control how often usage updates are sent during streaming
61+var (
62+ usageUpdateCharThreshold = 5000 // Send usage update every 5000 characters
63+ usageUpdateTimeInterval = 15 * time.Second // Or every 15 seconds, whichever comes first
64+)
65+
66+// kiroEndpointConfig bundles endpoint URL with its compatible Origin and AmzTarget values.
67+// This solves the "triple mismatch" problem where different endpoints require matching
68+// Origin and X-Amz-Target header values.
69+//
70+// Based on reference implementations:
71+// - amq2api-main: Uses Amazon Q endpoint with CLI origin and AmazonQDeveloperStreamingService target
72+// - AIClient-2-API: Uses CodeWhisperer endpoint with AI_EDITOR origin and AmazonCodeWhispererStreamingService target
73+type kiroEndpointConfig struct {
74+ URL string // Endpoint URL
75+ Origin string // Request Origin: "CLI" for Amazon Q quota, "AI_EDITOR" for Kiro IDE quota
76+ AmzTarget string // X-Amz-Target header value
77+ Name string // Endpoint name for logging
78+}
79+
80+// kiroEndpointConfigs defines the available Kiro API endpoints with their compatible configurations.
81+// The order determines fallback priority: primary endpoint first, then fallbacks.
82+//
83+// CRITICAL: Each endpoint MUST use its compatible Origin and AmzTarget values:
84+// - CodeWhisperer endpoint (codewhisperer.us-east-1.amazonaws.com): Uses AI_EDITOR origin and AmazonCodeWhispererStreamingService target
85+// - Amazon Q endpoint (q.us-east-1.amazonaws.com): Uses CLI origin and AmazonQDeveloperStreamingService target
86+//
87+// Mismatched combinations will result in 403 Forbidden errors.
88+//
89+// NOTE: CodeWhisperer is set as the default endpoint because:
90+// 1. Most tokens come from Kiro IDE / VSCode extensions (AWS Builder ID auth)
91+// 2. These tokens use AI_EDITOR origin which is only compatible with CodeWhisperer endpoint
92+// 3. Amazon Q endpoint requires CLI origin which is for Amazon Q CLI tokens
93+// This matches the AIClient-2-API-main project's configuration.
94+var kiroEndpointConfigs = []kiroEndpointConfig{
95+ {
96+ URL: "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse",
97+ Origin: "AI_EDITOR",
98+ AmzTarget: "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
99+ Name: "CodeWhisperer",
100+ },
101+ {
102+ URL: "https://q.us-east-1.amazonaws.com/",
103+ Origin: "CLI",
104+ AmzTarget: "AmazonQDeveloperStreamingService.SendMessage",
105+ Name: "AmazonQ",
106+ },
107+}
108+
109+// getKiroEndpointConfigs returns the list of Kiro API endpoint configurations to try in order.
110+// Supports reordering based on "preferred_endpoint" in auth metadata/attributes.
111+// For IDC auth method, automatically uses CodeWhisperer endpoint with CLI origin.
112+func getKiroEndpointConfigs(auth *cliproxyauth.Auth) []kiroEndpointConfig {
113+ if auth == nil {
114+ return kiroEndpointConfigs
115+ }
116+
117+ // For IDC auth, use CodeWhisperer endpoint with AI_EDITOR origin (same as Social auth)
118+ // Based on kiro2api analysis: IDC tokens work with CodeWhisperer endpoint using Bearer auth
119+ // The difference is only in how tokens are refreshed (OIDC with clientId/clientSecret for IDC)
120+ // NOT in how API calls are made - both Social and IDC use the same endpoint/origin
121+ if auth.Metadata != nil {
122+ authMethod, _ := auth.Metadata["auth_method"].(string)
123+ if authMethod == "idc" {
124+ log.Debugf("kiro: IDC auth, using CodeWhisperer endpoint")
125+ return kiroEndpointConfigs
126+ }
127+ }
128+
129+ // Check for preference
130+ var preference string
131+ if auth.Metadata != nil {
132+ if p, ok := auth.Metadata["preferred_endpoint"].(string); ok {
133+ preference = p
134+ }
135+ }
136+ // Check attributes as fallback (e.g. from HTTP headers)
137+ if preference == "" && auth.Attributes != nil {
138+ preference = auth.Attributes["preferred_endpoint"]
139+ }
140+
141+ if preference == "" {
142+ return kiroEndpointConfigs
143+ }
144+
145+ preference = strings.ToLower(strings.TrimSpace(preference))
146+
147+ // Create new slice to avoid modifying global state
148+ var sorted []kiroEndpointConfig
149+ var remaining []kiroEndpointConfig
150+
151+ for _, cfg := range kiroEndpointConfigs {
152+ name := strings.ToLower(cfg.Name)
153+ // Check for matches
154+ // CodeWhisperer aliases: codewhisperer, ide
155+ // AmazonQ aliases: amazonq, q, cli
156+ isMatch := false
157+ if (preference == "codewhisperer" || preference == "ide") && name == "codewhisperer" {
158+ isMatch = true
159+ } else if (preference == "amazonq" || preference == "q" || preference == "cli") && name == "amazonq" {
160+ isMatch = true
161+ }
162+
163+ if isMatch {
164+ sorted = append(sorted, cfg)
165+ } else {
166+ remaining = append(remaining, cfg)
167+ }
168+ }
169+
170+ // If preference didn't match anything, return default
171+ if len(sorted) == 0 {
172+ return kiroEndpointConfigs
173+ }
174+
175+ // Combine: preferred first, then others
176+ return append(sorted, remaining...)
177+}
178+
179+// KiroExecutor handles requests to AWS CodeWhisperer (Kiro) API.
180+type KiroExecutor struct {
181+ cfg *config.Config
182+ refreshMu sync.Mutex // Serializes token refresh operations to prevent race conditions
183+}
184+
185+// isIDCAuth checks if the auth uses IDC (Identity Center) authentication method.
186+func isIDCAuth(auth *cliproxyauth.Auth) bool {
187+ if auth == nil || auth.Metadata == nil {
188+ return false
189+ }
190+ authMethod, _ := auth.Metadata["auth_method"].(string)
191+ return authMethod == "idc"
192+}
193+
194+// buildKiroPayloadForFormat builds the Kiro API payload based on the source format.
195+// This is critical because OpenAI and Claude formats have different tool structures:
196+// - OpenAI: tools[].function.name, tools[].function.description
197+// - Claude: tools[].name, tools[].description
198+// headers parameter allows checking Anthropic-Beta header for thinking mode detection.
199+// Returns the serialized JSON payload and a boolean indicating whether thinking mode was injected.
200+func buildKiroPayloadForFormat(body []byte, modelID, profileArn, origin string, isAgentic, isChatOnly bool, sourceFormat sdktranslator.Format, headers http.Header) ([]byte, bool) {
201+ switch sourceFormat.String() {
202+ case "openai":
203+ log.Debugf("kiro: using OpenAI payload builder for source format: %s", sourceFormat.String())
204+ return kiroopenai.BuildKiroPayloadFromOpenAI(body, modelID, profileArn, origin, isAgentic, isChatOnly, headers, nil)
205+ default:
206+ // Default to Claude format (also handles "claude", "kiro", etc.)
207+ log.Debugf("kiro: using Claude payload builder for source format: %s", sourceFormat.String())
208+ return kiroclaude.BuildKiroPayload(body, modelID, profileArn, origin, isAgentic, isChatOnly, headers, nil)
209+ }
210+}
211+
212+// NewKiroExecutor creates a new Kiro executor instance.
213+func NewKiroExecutor(cfg *config.Config) *KiroExecutor {
214+ return &KiroExecutor{cfg: cfg}
215+}
216+
217+// Identifier returns the unique identifier for this executor.
218+func (e *KiroExecutor) Identifier() string { return "kiro" }
219+
220+// PrepareRequest prepares the HTTP request before execution.
221+func (e *KiroExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
222+
223+// Execute sends the request to Kiro API and returns the response.
224+// Supports automatic token refresh on 401/403 errors.
225+func (e *KiroExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
226+ accessToken, profileArn := kiroCredentials(auth)
227+ if accessToken == "" {
228+ return resp, fmt.Errorf("kiro: access token not found in auth")
229+ }
230+
231+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
232+ defer reporter.trackFailure(ctx, &err)
233+
234+ // Check if token is expired before making request
235+ if e.isTokenExpired(accessToken) {
236+ log.Infof("kiro: access token expired, attempting refresh before request")
237+ refreshedAuth, refreshErr := e.Refresh(ctx, auth)
238+ if refreshErr != nil {
239+ log.Warnf("kiro: pre-request token refresh failed: %v", refreshErr)
240+ } else if refreshedAuth != nil {
241+ auth = refreshedAuth
242+ // Persist the refreshed auth to file so subsequent requests use it
243+ if persistErr := e.persistRefreshedAuth(auth); persistErr != nil {
244+ log.Warnf("kiro: failed to persist refreshed auth: %v", persistErr)
245+ }
246+ accessToken, profileArn = kiroCredentials(auth)
247+ log.Infof("kiro: token refreshed successfully before request")
248+ }
249+ }
250+
251+ from := opts.SourceFormat
252+ to := sdktranslator.FromString("kiro")
253+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
254+
255+ kiroModelID := e.mapModelToKiro(req.Model)
256+
257+ // Determine agentic mode and effective profile ARN using helper functions
258+ isAgentic, isChatOnly := determineAgenticMode(req.Model)
259+ effectiveProfileArn := getEffectiveProfileArnWithWarning(auth, profileArn)
260+
261+ // Execute with retry on 401/403 and 429 (quota exhausted)
262+ // Note: currentOrigin and kiroPayload are built inside executeWithRetry for each endpoint
263+ resp, err = e.executeWithRetry(ctx, auth, req, opts, accessToken, effectiveProfileArn, nil, body, from, to, reporter, "", kiroModelID, isAgentic, isChatOnly)
264+ return resp, err
265+}
266+
267+// executeWithRetry performs the actual HTTP request with automatic retry on auth errors.
268+// Supports automatic fallback between endpoints with different quotas:
269+// - Amazon Q endpoint (CLI origin) uses Amazon Q Developer quota
270+// - CodeWhisperer endpoint (AI_EDITOR origin) uses Kiro IDE quota
271+// Also supports multi-endpoint fallback similar to Antigravity implementation.
272+func (e *KiroExecutor) executeWithRetry(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, accessToken, profileArn string, kiroPayload, body []byte, from, to sdktranslator.Format, reporter *usageReporter, currentOrigin, kiroModelID string, isAgentic, isChatOnly bool) (cliproxyexecutor.Response, error) {
273+ var resp cliproxyexecutor.Response
274+ maxRetries := 2 // Allow retries for token refresh + endpoint fallback
275+ endpointConfigs := getKiroEndpointConfigs(auth)
276+ var last429Err error
277+
278+ for endpointIdx := 0; endpointIdx < len(endpointConfigs); endpointIdx++ {
279+ endpointConfig := endpointConfigs[endpointIdx]
280+ url := endpointConfig.URL
281+ // Use this endpoint's compatible Origin (critical for avoiding 403 errors)
282+ currentOrigin = endpointConfig.Origin
283+
284+ // Rebuild payload with the correct origin for this endpoint
285+ // Each endpoint requires its matching Origin value in the request body
286+ kiroPayload, _ = buildKiroPayloadForFormat(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly, from, opts.Headers)
287+
288+ log.Debugf("kiro: trying endpoint %d/%d: %s (Name: %s, Origin: %s)",
289+ endpointIdx+1, len(endpointConfigs), url, endpointConfig.Name, currentOrigin)
290+
291+ for attempt := 0; attempt <= maxRetries; attempt++ {
292+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(kiroPayload))
293+ if err != nil {
294+ return resp, err
295+ }
296+
297+ httpReq.Header.Set("Content-Type", kiroContentType)
298+ httpReq.Header.Set("Accept", kiroAcceptStream)
299+ // Use endpoint-specific X-Amz-Target (critical for avoiding 403 errors)
300+ httpReq.Header.Set("X-Amz-Target", endpointConfig.AmzTarget)
301+
302+ // Use different headers based on auth type
303+ // IDC auth uses Kiro IDE style headers (from kiro2api)
304+ // Other auth types use Amazon Q CLI style headers
305+ if isIDCAuth(auth) {
306+ httpReq.Header.Set("User-Agent", kiroIDEUserAgent)
307+ httpReq.Header.Set("X-Amz-User-Agent", kiroIDEAmzUserAgent)
308+ httpReq.Header.Set("x-amzn-kiro-agent-mode", kiroIDEAgentModeSpec)
309+ log.Debugf("kiro: using Kiro IDE headers for IDC auth")
310+ } else {
311+ httpReq.Header.Set("User-Agent", kiroUserAgent)
312+ httpReq.Header.Set("X-Amz-User-Agent", kiroFullUserAgent)
313+ }
314+ httpReq.Header.Set("Amz-Sdk-Request", "attempt=1; max=3")
315+ httpReq.Header.Set("Amz-Sdk-Invocation-Id", uuid.New().String())
316+
317+ // Bearer token authentication for all auth types (Builder ID, IDC, social, etc.)
318+ httpReq.Header.Set("Authorization", "Bearer "+accessToken)
319+
320+ var attrs map[string]string
321+ if auth != nil {
322+ attrs = auth.Attributes
323+ }
324+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
325+
326+ var authID, authLabel, authType, authValue string
327+ if auth != nil {
328+ authID = auth.ID
329+ authLabel = auth.Label
330+ authType, authValue = auth.AccountInfo()
331+ }
332+ recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
333+ URL: url,
334+ Method: http.MethodPost,
335+ Headers: httpReq.Header.Clone(),
336+ Body: kiroPayload,
337+ Provider: e.Identifier(),
338+ AuthID: authID,
339+ AuthLabel: authLabel,
340+ AuthType: authType,
341+ AuthValue: authValue,
342+ })
343+
344+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 120*time.Second)
345+ httpResp, err := httpClient.Do(httpReq)
346+ if err != nil {
347+ recordAPIResponseError(ctx, e.cfg, err)
348+ return resp, err
349+ }
350+ recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
351+
352+ // Handle 429 errors (quota exhausted) - try next endpoint
353+ // Each endpoint has its own quota pool, so we can try different endpoints
354+ if httpResp.StatusCode == 429 {
355+ respBody, _ := io.ReadAll(httpResp.Body)
356+ _ = httpResp.Body.Close()
357+ appendAPIResponseChunk(ctx, e.cfg, respBody)
358+
359+ // Preserve last 429 so callers can correctly backoff when all endpoints are exhausted
360+ last429Err = statusErr{code: httpResp.StatusCode, msg: string(respBody)}
361+
362+ log.Warnf("kiro: %s endpoint quota exhausted (429), will try next endpoint, body: %s",
363+ endpointConfig.Name, summarizeErrorBody(httpResp.Header.Get("Content-Type"), respBody))
364+
365+ // Break inner retry loop to try next endpoint (which has different quota)
366+ break
367+ }
368+
369+ // Handle 5xx server errors with exponential backoff retry
370+ if httpResp.StatusCode >= 500 && httpResp.StatusCode < 600 {
371+ respBody, _ := io.ReadAll(httpResp.Body)
372+ _ = httpResp.Body.Close()
373+ appendAPIResponseChunk(ctx, e.cfg, respBody)
374+
375+ if attempt < maxRetries {
376+ // Exponential backoff: 1s, 2s, 4s... (max 30s)
377+ backoff := time.Duration(1<<attempt) * time.Second
378+ if backoff > 30*time.Second {
379+ backoff = 30 * time.Second
380+ }
381+ log.Warnf("kiro: server error %d, retrying in %v (attempt %d/%d)", httpResp.StatusCode, backoff, attempt+1, maxRetries)
382+ time.Sleep(backoff)
383+ continue
384+ }
385+ log.Errorf("kiro: server error %d after %d retries", httpResp.StatusCode, maxRetries)
386+ return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
387+ }
388+
389+ // Handle 401 errors with token refresh and retry
390+ // 401 = Unauthorized (token expired/invalid) - refresh token
391+ if httpResp.StatusCode == 401 {
392+ respBody, _ := io.ReadAll(httpResp.Body)
393+ _ = httpResp.Body.Close()
394+ appendAPIResponseChunk(ctx, e.cfg, respBody)
395+
396+ if attempt < maxRetries {
397+ log.Warnf("kiro: received 401 error, attempting token refresh and retry (attempt %d/%d)", attempt+1, maxRetries+1)
398+
399+ refreshedAuth, refreshErr := e.Refresh(ctx, auth)
400+ if refreshErr != nil {
401+ log.Errorf("kiro: token refresh failed: %v", refreshErr)
402+ return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
403+ }
404+
405+ if refreshedAuth != nil {
406+ auth = refreshedAuth
407+ // Persist the refreshed auth to file so subsequent requests use it
408+ if persistErr := e.persistRefreshedAuth(auth); persistErr != nil {
409+ log.Warnf("kiro: failed to persist refreshed auth: %v", persistErr)
410+ // Continue anyway - the token is valid for this request
411+ }
412+ accessToken, profileArn = kiroCredentials(auth)
413+ // Rebuild payload with new profile ARN if changed
414+ kiroPayload, _ = buildKiroPayloadForFormat(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly, from, opts.Headers)
415+ log.Infof("kiro: token refreshed successfully, retrying request")
416+ continue
417+ }
418+ }
419+
420+ log.Warnf("kiro request error, status: 401, body: %s", summarizeErrorBody(httpResp.Header.Get("Content-Type"), respBody))
421+ return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
422+ }
423+
424+ // Handle 402 errors - Monthly Limit Reached
425+ if httpResp.StatusCode == 402 {
426+ respBody, _ := io.ReadAll(httpResp.Body)
427+ _ = httpResp.Body.Close()
428+ appendAPIResponseChunk(ctx, e.cfg, respBody)
429+
430+ log.Warnf("kiro: received 402 (monthly limit). Upstream body: %s", string(respBody))
431+
432+ // Return upstream error body directly
433+ return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
434+ }
435+
436+ // Handle 403 errors - Access Denied / Token Expired
437+ // Do NOT switch endpoints for 403 errors
438+ if httpResp.StatusCode == 403 {
439+ respBody, _ := io.ReadAll(httpResp.Body)
440+ _ = httpResp.Body.Close()
441+ appendAPIResponseChunk(ctx, e.cfg, respBody)
442+
443+ // Log the 403 error details for debugging
444+ log.Warnf("kiro: received 403 error (attempt %d/%d), body: %s", attempt+1, maxRetries+1, summarizeErrorBody(httpResp.Header.Get("Content-Type"), respBody))
445+
446+ respBodyStr := string(respBody)
447+
448+ // Check for SUSPENDED status - return immediately without retry
449+ if strings.Contains(respBodyStr, "SUSPENDED") || strings.Contains(respBodyStr, "TEMPORARILY_SUSPENDED") {
450+ log.Errorf("kiro: account is suspended, cannot proceed")
451+ return resp, statusErr{code: httpResp.StatusCode, msg: "account suspended: " + string(respBody)}
452+ }
453+
454+ // Check if this looks like a token-related 403 (some APIs return 403 for expired tokens)
455+ isTokenRelated := strings.Contains(respBodyStr, "token") ||
456+ strings.Contains(respBodyStr, "expired") ||
457+ strings.Contains(respBodyStr, "invalid") ||
458+ strings.Contains(respBodyStr, "unauthorized")
459+
460+ if isTokenRelated && attempt < maxRetries {
461+ log.Warnf("kiro: 403 appears token-related, attempting token refresh")
462+ refreshedAuth, refreshErr := e.Refresh(ctx, auth)
463+ if refreshErr != nil {
464+ log.Errorf("kiro: token refresh failed: %v", refreshErr)
465+ // Token refresh failed - return error immediately
466+ return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
467+ }
468+ if refreshedAuth != nil {
469+ auth = refreshedAuth
470+ // Persist the refreshed auth to file so subsequent requests use it
471+ if persistErr := e.persistRefreshedAuth(auth); persistErr != nil {
472+ log.Warnf("kiro: failed to persist refreshed auth: %v", persistErr)
473+ // Continue anyway - the token is valid for this request
474+ }
475+ accessToken, profileArn = kiroCredentials(auth)
476+ kiroPayload, _ = buildKiroPayloadForFormat(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly, from, opts.Headers)
477+ log.Infof("kiro: token refreshed for 403, retrying request")
478+ continue
479+ }
480+ }
481+
482+ // For non-token 403 or after max retries, return error immediately
483+ // Do NOT switch endpoints for 403 errors
484+ log.Warnf("kiro: 403 error, returning immediately (no endpoint switch)")
485+ return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
486+ }
487+
488+ if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
489+ b, _ := io.ReadAll(httpResp.Body)
490+ appendAPIResponseChunk(ctx, e.cfg, b)
491+ log.Debugf("kiro request error, status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
492+ err = statusErr{code: httpResp.StatusCode, msg: string(b)}
493+ if errClose := httpResp.Body.Close(); errClose != nil {
494+ log.Errorf("response body close error: %v", errClose)
495+ }
496+ return resp, err
497+ }
498+
499+ defer func() {
500+ if errClose := httpResp.Body.Close(); errClose != nil {
501+ log.Errorf("response body close error: %v", errClose)
502+ }
503+ }()
504+
505+ content, toolUses, usageInfo, stopReason, err := e.parseEventStream(httpResp.Body)
506+ if err != nil {
507+ recordAPIResponseError(ctx, e.cfg, err)
508+ return resp, err
509+ }
510+
511+ // Fallback for usage if missing from upstream
512+ if usageInfo.TotalTokens == 0 {
513+ if enc, encErr := getTokenizer(req.Model); encErr == nil {
514+ if inp, countErr := countOpenAIChatTokens(enc, opts.OriginalRequest); countErr == nil {
515+ usageInfo.InputTokens = inp
516+ }
517+ }
518+ if len(content) > 0 {
519+ // Use tiktoken for more accurate output token calculation
520+ if enc, encErr := getTokenizer(req.Model); encErr == nil {
521+ if tokenCount, countErr := enc.Count(content); countErr == nil {
522+ usageInfo.OutputTokens = int64(tokenCount)
523+ }
524+ }
525+ // Fallback to character count estimation if tiktoken fails
526+ if usageInfo.OutputTokens == 0 {
527+ usageInfo.OutputTokens = int64(len(content) / 4)
528+ if usageInfo.OutputTokens == 0 {
529+ usageInfo.OutputTokens = 1
530+ }
531+ }
532+ }
533+ usageInfo.TotalTokens = usageInfo.InputTokens + usageInfo.OutputTokens
534+ }
535+
536+ appendAPIResponseChunk(ctx, e.cfg, []byte(content))
537+ reporter.publish(ctx, usageInfo)
538+
539+ // Build response in Claude format for Kiro translator
540+ // stopReason is extracted from upstream response by parseEventStream
541+ kiroResponse := kiroclaude.BuildClaudeResponse(content, toolUses, req.Model, usageInfo, stopReason)
542+ out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, kiroResponse, nil)
543+ resp = cliproxyexecutor.Response{Payload: []byte(out)}
544+ return resp, nil
545+ }
546+ // Inner retry loop exhausted for this endpoint, try next endpoint
547+ // Note: This code is unreachable because all paths in the inner loop
548+ // either return or continue. Kept as comment for documentation.
549+ }
550+
551+ // All endpoints exhausted
552+ if last429Err != nil {
553+ return resp, last429Err
554+ }
555+ return resp, fmt.Errorf("kiro: all endpoints exhausted")
556+}
557+
558+// ExecuteStream handles streaming requests to Kiro API.
559+// Supports automatic token refresh on 401/403 errors and quota fallback on 429.
560+func (e *KiroExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
561+ accessToken, profileArn := kiroCredentials(auth)
562+ if accessToken == "" {
563+ return nil, fmt.Errorf("kiro: access token not found in auth")
564+ }
565+
566+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
567+ defer reporter.trackFailure(ctx, &err)
568+
569+ // Check if token is expired before making request
570+ if e.isTokenExpired(accessToken) {
571+ log.Infof("kiro: access token expired, attempting refresh before stream request")
572+ refreshedAuth, refreshErr := e.Refresh(ctx, auth)
573+ if refreshErr != nil {
574+ log.Warnf("kiro: pre-request token refresh failed: %v", refreshErr)
575+ } else if refreshedAuth != nil {
576+ auth = refreshedAuth
577+ // Persist the refreshed auth to file so subsequent requests use it
578+ if persistErr := e.persistRefreshedAuth(auth); persistErr != nil {
579+ log.Warnf("kiro: failed to persist refreshed auth: %v", persistErr)
580+ }
581+ accessToken, profileArn = kiroCredentials(auth)
582+ log.Infof("kiro: token refreshed successfully before stream request")
583+ }
584+ }
585+
586+ from := opts.SourceFormat
587+ to := sdktranslator.FromString("kiro")
588+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
589+
590+ kiroModelID := e.mapModelToKiro(req.Model)
591+
592+ // Determine agentic mode and effective profile ARN using helper functions
593+ isAgentic, isChatOnly := determineAgenticMode(req.Model)
594+ effectiveProfileArn := getEffectiveProfileArnWithWarning(auth, profileArn)
595+
596+ // Execute stream with retry on 401/403 and 429 (quota exhausted)
597+ // Note: currentOrigin and kiroPayload are built inside executeStreamWithRetry for each endpoint
598+ return e.executeStreamWithRetry(ctx, auth, req, opts, accessToken, effectiveProfileArn, nil, body, from, reporter, "", kiroModelID, isAgentic, isChatOnly)
599+}
600+
601+// executeStreamWithRetry performs the streaming HTTP request with automatic retry on auth errors.
602+// Supports automatic fallback between endpoints with different quotas:
603+// - Amazon Q endpoint (CLI origin) uses Amazon Q Developer quota
604+// - CodeWhisperer endpoint (AI_EDITOR origin) uses Kiro IDE quota
605+// Also supports multi-endpoint fallback similar to Antigravity implementation.
606+func (e *KiroExecutor) executeStreamWithRetry(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, accessToken, profileArn string, kiroPayload, body []byte, from sdktranslator.Format, reporter *usageReporter, currentOrigin, kiroModelID string, isAgentic, isChatOnly bool) (<-chan cliproxyexecutor.StreamChunk, error) {
607+ maxRetries := 2 // Allow retries for token refresh + endpoint fallback
608+ endpointConfigs := getKiroEndpointConfigs(auth)
609+ var last429Err error
610+
611+ for endpointIdx := 0; endpointIdx < len(endpointConfigs); endpointIdx++ {
612+ endpointConfig := endpointConfigs[endpointIdx]
613+ url := endpointConfig.URL
614+ // Use this endpoint's compatible Origin (critical for avoiding 403 errors)
615+ currentOrigin = endpointConfig.Origin
616+
617+ // Rebuild payload with the correct origin for this endpoint
618+ // Each endpoint requires its matching Origin value in the request body
619+ kiroPayload, thinkingEnabled := buildKiroPayloadForFormat(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly, from, opts.Headers)
620+
621+ log.Debugf("kiro: stream trying endpoint %d/%d: %s (Name: %s, Origin: %s)",
622+ endpointIdx+1, len(endpointConfigs), url, endpointConfig.Name, currentOrigin)
623+
624+ for attempt := 0; attempt <= maxRetries; attempt++ {
625+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(kiroPayload))
626+ if err != nil {
627+ return nil, err
628+ }
629+
630+ httpReq.Header.Set("Content-Type", kiroContentType)
631+ httpReq.Header.Set("Accept", kiroAcceptStream)
632+ // Use endpoint-specific X-Amz-Target (critical for avoiding 403 errors)
633+ httpReq.Header.Set("X-Amz-Target", endpointConfig.AmzTarget)
634+
635+ // Use different headers based on auth type
636+ // IDC auth uses Kiro IDE style headers (from kiro2api)
637+ // Other auth types use Amazon Q CLI style headers
638+ if isIDCAuth(auth) {
639+ httpReq.Header.Set("User-Agent", kiroIDEUserAgent)
640+ httpReq.Header.Set("X-Amz-User-Agent", kiroIDEAmzUserAgent)
641+ httpReq.Header.Set("x-amzn-kiro-agent-mode", kiroIDEAgentModeSpec)
642+ log.Debugf("kiro: using Kiro IDE headers for IDC auth")
643+ } else {
644+ httpReq.Header.Set("User-Agent", kiroUserAgent)
645+ httpReq.Header.Set("X-Amz-User-Agent", kiroFullUserAgent)
646+ }
647+ httpReq.Header.Set("Amz-Sdk-Request", "attempt=1; max=3")
648+ httpReq.Header.Set("Amz-Sdk-Invocation-Id", uuid.New().String())
649+
650+ // Bearer token authentication for all auth types (Builder ID, IDC, social, etc.)
651+ httpReq.Header.Set("Authorization", "Bearer "+accessToken)
652+
653+ var attrs map[string]string
654+ if auth != nil {
655+ attrs = auth.Attributes
656+ }
657+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
658+
659+ var authID, authLabel, authType, authValue string
660+ if auth != nil {
661+ authID = auth.ID
662+ authLabel = auth.Label
663+ authType, authValue = auth.AccountInfo()
664+ }
665+ recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
666+ URL: url,
667+ Method: http.MethodPost,
668+ Headers: httpReq.Header.Clone(),
669+ Body: kiroPayload,
670+ Provider: e.Identifier(),
671+ AuthID: authID,
672+ AuthLabel: authLabel,
673+ AuthType: authType,
674+ AuthValue: authValue,
675+ })
676+
677+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
678+ httpResp, err := httpClient.Do(httpReq)
679+ if err != nil {
680+ recordAPIResponseError(ctx, e.cfg, err)
681+ return nil, err
682+ }
683+ recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
684+
685+ // Handle 429 errors (quota exhausted) - try next endpoint
686+ // Each endpoint has its own quota pool, so we can try different endpoints
687+ if httpResp.StatusCode == 429 {
688+ respBody, _ := io.ReadAll(httpResp.Body)
689+ _ = httpResp.Body.Close()
690+ appendAPIResponseChunk(ctx, e.cfg, respBody)
691+
692+ // Preserve last 429 so callers can correctly backoff when all endpoints are exhausted
693+ last429Err = statusErr{code: httpResp.StatusCode, msg: string(respBody)}
694+
695+ log.Warnf("kiro: stream %s endpoint quota exhausted (429), will try next endpoint, body: %s",
696+ endpointConfig.Name, summarizeErrorBody(httpResp.Header.Get("Content-Type"), respBody))
697+
698+ // Break inner retry loop to try next endpoint (which has different quota)
699+ break
700+ }
701+
702+ // Handle 5xx server errors with exponential backoff retry
703+ if httpResp.StatusCode >= 500 && httpResp.StatusCode < 600 {
704+ respBody, _ := io.ReadAll(httpResp.Body)
705+ _ = httpResp.Body.Close()
706+ appendAPIResponseChunk(ctx, e.cfg, respBody)
707+
708+ if attempt < maxRetries {
709+ // Exponential backoff: 1s, 2s, 4s... (max 30s)
710+ backoff := time.Duration(1<<attempt) * time.Second
711+ if backoff > 30*time.Second {
712+ backoff = 30 * time.Second
713+ }
714+ log.Warnf("kiro: stream server error %d, retrying in %v (attempt %d/%d)", httpResp.StatusCode, backoff, attempt+1, maxRetries)
715+ time.Sleep(backoff)
716+ continue
717+ }
718+ log.Errorf("kiro: stream server error %d after %d retries", httpResp.StatusCode, maxRetries)
719+ return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
720+ }
721+
722+ // Handle 400 errors - Credential/Validation issues
723+ // Do NOT switch endpoints - return error immediately
724+ if httpResp.StatusCode == 400 {
725+ respBody, _ := io.ReadAll(httpResp.Body)
726+ _ = httpResp.Body.Close()
727+ appendAPIResponseChunk(ctx, e.cfg, respBody)
728+
729+ log.Warnf("kiro: received 400 error (attempt %d/%d), body: %s", attempt+1, maxRetries+1, summarizeErrorBody(httpResp.Header.Get("Content-Type"), respBody))
730+
731+ // 400 errors indicate request validation issues - return immediately without retry
732+ return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
733+ }
734+
735+ // Handle 401 errors with token refresh and retry
736+ // 401 = Unauthorized (token expired/invalid) - refresh token
737+ if httpResp.StatusCode == 401 {
738+ respBody, _ := io.ReadAll(httpResp.Body)
739+ _ = httpResp.Body.Close()
740+ appendAPIResponseChunk(ctx, e.cfg, respBody)
741+
742+ if attempt < maxRetries {
743+ log.Warnf("kiro: stream received 401 error, attempting token refresh and retry (attempt %d/%d)", attempt+1, maxRetries+1)
744+
745+ refreshedAuth, refreshErr := e.Refresh(ctx, auth)
746+ if refreshErr != nil {
747+ log.Errorf("kiro: token refresh failed: %v", refreshErr)
748+ return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
749+ }
750+
751+ if refreshedAuth != nil {
752+ auth = refreshedAuth
753+ // Persist the refreshed auth to file so subsequent requests use it
754+ if persistErr := e.persistRefreshedAuth(auth); persistErr != nil {
755+ log.Warnf("kiro: failed to persist refreshed auth: %v", persistErr)
756+ // Continue anyway - the token is valid for this request
757+ }
758+ accessToken, profileArn = kiroCredentials(auth)
759+ // Rebuild payload with new profile ARN if changed
760+ kiroPayload, _ = buildKiroPayloadForFormat(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly, from, opts.Headers)
761+ log.Infof("kiro: token refreshed successfully, retrying stream request")
762+ continue
763+ }
764+ }
765+
766+ log.Warnf("kiro stream error, status: 401, body: %s", string(respBody))
767+ return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
768+ }
769+
770+ // Handle 402 errors - Monthly Limit Reached
771+ if httpResp.StatusCode == 402 {
772+ respBody, _ := io.ReadAll(httpResp.Body)
773+ _ = httpResp.Body.Close()
774+ appendAPIResponseChunk(ctx, e.cfg, respBody)
775+
776+ log.Warnf("kiro: stream received 402 (monthly limit). Upstream body: %s", string(respBody))
777+
778+ // Return upstream error body directly
779+ return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
780+ }
781+
782+ // Handle 403 errors - Access Denied / Token Expired
783+ // Do NOT switch endpoints for 403 errors
784+ if httpResp.StatusCode == 403 {
785+ respBody, _ := io.ReadAll(httpResp.Body)
786+ _ = httpResp.Body.Close()
787+ appendAPIResponseChunk(ctx, e.cfg, respBody)
788+
789+ // Log the 403 error details for debugging
790+ log.Warnf("kiro: stream received 403 error (attempt %d/%d), body: %s", attempt+1, maxRetries+1, string(respBody))
791+
792+ respBodyStr := string(respBody)
793+
794+ // Check for SUSPENDED status - return immediately without retry
795+ if strings.Contains(respBodyStr, "SUSPENDED") || strings.Contains(respBodyStr, "TEMPORARILY_SUSPENDED") {
796+ log.Errorf("kiro: account is suspended, cannot proceed")
797+ return nil, statusErr{code: httpResp.StatusCode, msg: "account suspended: " + string(respBody)}
798+ }
799+
800+ // Check if this looks like a token-related 403 (some APIs return 403 for expired tokens)
801+ isTokenRelated := strings.Contains(respBodyStr, "token") ||
802+ strings.Contains(respBodyStr, "expired") ||
803+ strings.Contains(respBodyStr, "invalid") ||
804+ strings.Contains(respBodyStr, "unauthorized")
805+
806+ if isTokenRelated && attempt < maxRetries {
807+ log.Warnf("kiro: 403 appears token-related, attempting token refresh")
808+ refreshedAuth, refreshErr := e.Refresh(ctx, auth)
809+ if refreshErr != nil {
810+ log.Errorf("kiro: token refresh failed: %v", refreshErr)
811+ // Token refresh failed - return error immediately
812+ return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
813+ }
814+ if refreshedAuth != nil {
815+ auth = refreshedAuth
816+ // Persist the refreshed auth to file so subsequent requests use it
817+ if persistErr := e.persistRefreshedAuth(auth); persistErr != nil {
818+ log.Warnf("kiro: failed to persist refreshed auth: %v", persistErr)
819+ // Continue anyway - the token is valid for this request
820+ }
821+ accessToken, profileArn = kiroCredentials(auth)
822+ kiroPayload, _ = buildKiroPayloadForFormat(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly, from, opts.Headers)
823+ log.Infof("kiro: token refreshed for 403, retrying stream request")
824+ continue
825+ }
826+ }
827+
828+ // For non-token 403 or after max retries, return error immediately
829+ // Do NOT switch endpoints for 403 errors
830+ log.Warnf("kiro: 403 error, returning immediately (no endpoint switch)")
831+ return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
832+ }
833+
834+ if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
835+ b, _ := io.ReadAll(httpResp.Body)
836+ appendAPIResponseChunk(ctx, e.cfg, b)
837+ log.Debugf("kiro stream error, status: %d, body: %s", httpResp.StatusCode, string(b))
838+ if errClose := httpResp.Body.Close(); errClose != nil {
839+ log.Errorf("response body close error: %v", errClose)
840+ }
841+ return nil, statusErr{code: httpResp.StatusCode, msg: string(b)}
842+ }
843+
844+ out := make(chan cliproxyexecutor.StreamChunk)
845+
846+ go func(resp *http.Response, thinkingEnabled bool) {
847+ defer close(out)
848+ defer func() {
849+ if r := recover(); r != nil {
850+ log.Errorf("kiro: panic in stream handler: %v", r)
851+ out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("internal error: %v", r)}
852+ }
853+ }()
854+ defer func() {
855+ if errClose := resp.Body.Close(); errClose != nil {
856+ log.Errorf("response body close error: %v", errClose)
857+ }
858+ }()
859+
860+ // Kiro API always returns <thinking> tags regardless of request parameters
861+ // So we always enable thinking parsing for Kiro responses
862+ log.Debugf("kiro: stream thinkingEnabled = %v (always true for Kiro)", thinkingEnabled)
863+
864+ e.streamToChannel(ctx, resp.Body, out, from, req.Model, opts.OriginalRequest, body, reporter, thinkingEnabled)
865+ }(httpResp, thinkingEnabled)
866+
867+ return out, nil
868+ }
869+ // Inner retry loop exhausted for this endpoint, try next endpoint
870+ // Note: This code is unreachable because all paths in the inner loop
871+ // either return or continue. Kept as comment for documentation.
872+ }
873+
874+ // All endpoints exhausted
875+ if last429Err != nil {
876+ return nil, last429Err
877+ }
878+ return nil, fmt.Errorf("kiro: stream all endpoints exhausted")
879+}
880+
881+// kiroCredentials extracts access token and profile ARN from auth.
882+func kiroCredentials(auth *cliproxyauth.Auth) (accessToken, profileArn string) {
883+ if auth == nil {
884+ return "", ""
885+ }
886+
887+ // Try Metadata first (wrapper format)
888+ if auth.Metadata != nil {
889+ if token, ok := auth.Metadata["access_token"].(string); ok {
890+ accessToken = token
891+ }
892+ if arn, ok := auth.Metadata["profile_arn"].(string); ok {
893+ profileArn = arn
894+ }
895+ }
896+
897+ // Try Attributes
898+ if accessToken == "" && auth.Attributes != nil {
899+ accessToken = auth.Attributes["access_token"]
900+ profileArn = auth.Attributes["profile_arn"]
901+ }
902+
903+ // Try direct fields from flat JSON format (new AWS Builder ID format)
904+ if accessToken == "" && auth.Metadata != nil {
905+ if token, ok := auth.Metadata["accessToken"].(string); ok {
906+ accessToken = token
907+ }
908+ if arn, ok := auth.Metadata["profileArn"].(string); ok {
909+ profileArn = arn
910+ }
911+ }
912+
913+ return accessToken, profileArn
914+}
915+
916+// findRealThinkingEndTag finds the real </thinking> end tag, skipping false positives.
917+// Returns -1 if no real end tag is found.
918+//
919+// Real </thinking> tags from Kiro API have specific characteristics:
920+// - Usually preceded by newline (.\n</thinking>)
921+// - Usually followed by newline (\n\n)
922+// - Not inside code blocks or inline code
923+//
924+// False positives (discussion text) have characteristics:
925+// - In the middle of a sentence
926+// - Preceded by discussion words like "标签", "tag", "returns"
927+// - Inside code blocks or inline code
928+//
929+// Parameters:
930+// - content: the content to search in
931+// - alreadyInCodeBlock: whether we're already inside a code block from previous chunks
932+// - alreadyInInlineCode: whether we're already inside inline code from previous chunks
933+func findRealThinkingEndTag(content string, alreadyInCodeBlock, alreadyInInlineCode bool) int {
934+ searchStart := 0
935+ for {
936+ endIdx := strings.Index(content[searchStart:], kirocommon.ThinkingEndTag)
937+ if endIdx < 0 {
938+ return -1
939+ }
940+ endIdx += searchStart // Adjust to absolute position
941+
942+ textBeforeEnd := content[:endIdx]
943+ textAfterEnd := content[endIdx+len(kirocommon.ThinkingEndTag):]
944+
945+ // Check 1: Is it inside inline code?
946+ // Count backticks in current content and add state from previous chunks
947+ backtickCount := strings.Count(textBeforeEnd, "`")
948+ effectiveInInlineCode := alreadyInInlineCode
949+ if backtickCount%2 == 1 {
950+ effectiveInInlineCode = !effectiveInInlineCode
951+ }
952+ if effectiveInInlineCode {
953+ log.Debugf("kiro: found </thinking> inside inline code at pos %d, skipping", endIdx)
954+ searchStart = endIdx + len(kirocommon.ThinkingEndTag)
955+ continue
956+ }
957+
958+ // Check 2: Is it inside a code block?
959+ // Count fences in current content and add state from previous chunks
960+ fenceCount := strings.Count(textBeforeEnd, "```")
961+ altFenceCount := strings.Count(textBeforeEnd, "~~~")
962+ effectiveInCodeBlock := alreadyInCodeBlock
963+ if fenceCount%2 == 1 || altFenceCount%2 == 1 {
964+ effectiveInCodeBlock = !effectiveInCodeBlock
965+ }
966+ if effectiveInCodeBlock {
967+ log.Debugf("kiro: found </thinking> inside code block at pos %d, skipping", endIdx)
968+ searchStart = endIdx + len(kirocommon.ThinkingEndTag)
969+ continue
970+ }
971+
972+ // Check 3: Real </thinking> tags are usually preceded by newline or at start
973+ // and followed by newline or at end. Check the format.
974+ charBeforeTag := byte(0)
975+ if endIdx > 0 {
976+ charBeforeTag = content[endIdx-1]
977+ }
978+ charAfterTag := byte(0)
979+ if len(textAfterEnd) > 0 {
980+ charAfterTag = textAfterEnd[0]
981+ }
982+
983+ // Real end tag format: preceded by newline OR end of sentence (. ! ?)
984+ // and followed by newline OR end of content
985+ isPrecededByNewlineOrSentenceEnd := charBeforeTag == '\n' || charBeforeTag == '.' ||
986+ charBeforeTag == '!' || charBeforeTag == '?' || charBeforeTag == 0
987+ isFollowedByNewlineOrEnd := charAfterTag == '\n' || charAfterTag == 0
988+
989+ // If the tag has proper formatting (newline before/after), it's likely real
990+ if isPrecededByNewlineOrSentenceEnd && isFollowedByNewlineOrEnd {
991+ log.Debugf("kiro: found properly formatted </thinking> at pos %d", endIdx)
992+ return endIdx
993+ }
994+
995+ // Check 4: Is the tag preceded by discussion keywords on the same line?
996+ lastNewlineIdx := strings.LastIndex(textBeforeEnd, "\n")
997+ lineBeforeTag := textBeforeEnd
998+ if lastNewlineIdx >= 0 {
999+ lineBeforeTag = textBeforeEnd[lastNewlineIdx+1:]
1000+ }
1001+ lineBeforeTagLower := strings.ToLower(lineBeforeTag)
1002+
1003+ // Discussion patterns - if found, this is likely discussion text
1004+ discussionPatterns := []string{
1005+ "标签", "返回", "输出", "包含", "使用", "解析", "转换", "生成", // Chinese
1006+ "tag", "return", "output", "contain", "use", "parse", "emit", "convert", "generate", // English
1007+ "<thinking>", // discussing both tags together
1008+ "`</thinking>`", // explicitly in inline code
1009+ }
1010+ isDiscussion := false
1011+ for _, pattern := range discussionPatterns {
1012+ if strings.Contains(lineBeforeTagLower, pattern) {
1013+ isDiscussion = true
1014+ break
1015+ }
1016+ }
1017+ if isDiscussion {
1018+ log.Debugf("kiro: found </thinking> after discussion text at pos %d, skipping", endIdx)
1019+ searchStart = endIdx + len(kirocommon.ThinkingEndTag)
1020+ continue
1021+ }
1022+
1023+ // Check 5: Is there text immediately after on the same line?
1024+ // Real end tags don't have text immediately after on the same line
1025+ if len(textAfterEnd) > 0 && charAfterTag != '\n' && charAfterTag != 0 {
1026+ // Find the next newline
1027+ nextNewline := strings.Index(textAfterEnd, "\n")
1028+ var textOnSameLine string
1029+ if nextNewline >= 0 {
1030+ textOnSameLine = textAfterEnd[:nextNewline]
1031+ } else {
1032+ textOnSameLine = textAfterEnd
1033+ }
1034+ // If there's non-whitespace text on the same line after the tag, it's discussion
1035+ if strings.TrimSpace(textOnSameLine) != "" {
1036+ log.Debugf("kiro: found </thinking> with text after on same line at pos %d, skipping", endIdx)
1037+ searchStart = endIdx + len(kirocommon.ThinkingEndTag)
1038+ continue
1039+ }
1040+ }
1041+
1042+ // Check 6: Is there another <thinking> tag after this </thinking>?
1043+ if strings.Contains(textAfterEnd, kirocommon.ThinkingStartTag) {
1044+ nextStartIdx := strings.Index(textAfterEnd, kirocommon.ThinkingStartTag)
1045+ textBeforeNextStart := textAfterEnd[:nextStartIdx]
1046+ nextBacktickCount := strings.Count(textBeforeNextStart, "`")
1047+ nextFenceCount := strings.Count(textBeforeNextStart, "```")
1048+ nextAltFenceCount := strings.Count(textBeforeNextStart, "~~~")
1049+
1050+ // If the next <thinking> is NOT in code, then this </thinking> is discussion text
1051+ if nextBacktickCount%2 == 0 && nextFenceCount%2 == 0 && nextAltFenceCount%2 == 0 {
1052+ log.Debugf("kiro: found </thinking> followed by <thinking> at pos %d, likely discussion text, skipping", endIdx)
1053+ searchStart = endIdx + len(kirocommon.ThinkingEndTag)
1054+ continue
1055+ }
1056+ }
1057+
1058+ // This looks like a real end tag
1059+ return endIdx
1060+ }
1061+}
1062+
1063+// determineAgenticMode determines if the model is an agentic or chat-only variant.
1064+// Returns (isAgentic, isChatOnly) based on model name suffixes.
1065+func determineAgenticMode(model string) (isAgentic, isChatOnly bool) {
1066+ isAgentic = strings.HasSuffix(model, "-agentic")
1067+ isChatOnly = strings.HasSuffix(model, "-chat")
1068+ return isAgentic, isChatOnly
1069+}
1070+
1071+// getEffectiveProfileArn determines if profileArn should be included based on auth method.
1072+// profileArn is only needed for social auth (Google OAuth), not for builder-id (AWS SSO).
1073+func getEffectiveProfileArn(auth *cliproxyauth.Auth, profileArn string) string {
1074+ if auth != nil && auth.Metadata != nil {
1075+ if authMethod, ok := auth.Metadata["auth_method"].(string); ok && authMethod == "builder-id" {
1076+ return "" // Don't include profileArn for builder-id auth
1077+ }
1078+ }
1079+ return profileArn
1080+}
1081+
1082+// getEffectiveProfileArnWithWarning determines if profileArn should be included based on auth method,
1083+// and logs a warning if profileArn is missing for non-builder-id auth.
1084+// This consolidates the auth_method check that was previously done separately.
1085+func getEffectiveProfileArnWithWarning(auth *cliproxyauth.Auth, profileArn string) string {
1086+ if auth != nil && auth.Metadata != nil {
1087+ if authMethod, ok := auth.Metadata["auth_method"].(string); ok && (authMethod == "builder-id" || authMethod == "idc") {
1088+ // builder-id and idc auth don't need profileArn
1089+ return ""
1090+ }
1091+ }
1092+ // For non-builder-id/idc auth (social auth), profileArn is required
1093+ if profileArn == "" {
1094+ log.Warnf("kiro: profile ARN not found in auth, API calls may fail")
1095+ }
1096+ return profileArn
1097+}
1098+
1099+// mapModelToKiro maps external model names to Kiro model IDs.
1100+// Supports both Kiro and Amazon Q prefixes since they use the same API.
1101+// Agentic variants (-agentic suffix) map to the same backend model IDs.
1102+func (e *KiroExecutor) mapModelToKiro(model string) string {
1103+ modelMap := map[string]string{
1104+ // Amazon Q format (amazonq- prefix) - same API as Kiro
1105+ "amazonq-auto": "auto",
1106+ "amazonq-claude-opus-4-5": "claude-opus-4.5",
1107+ "amazonq-claude-sonnet-4-5": "claude-sonnet-4.5",
1108+ "amazonq-claude-sonnet-4-5-20250929": "claude-sonnet-4.5",
1109+ "amazonq-claude-sonnet-4": "claude-sonnet-4",
1110+ "amazonq-claude-sonnet-4-20250514": "claude-sonnet-4",
1111+ "amazonq-claude-haiku-4-5": "claude-haiku-4.5",
1112+ // Kiro format (kiro- prefix) - valid model names that should be preserved
1113+ "kiro-claude-opus-4-5": "claude-opus-4.5",
1114+ "kiro-claude-sonnet-4-5": "claude-sonnet-4.5",
1115+ "kiro-claude-sonnet-4-5-20250929": "claude-sonnet-4.5",
1116+ "kiro-claude-sonnet-4": "claude-sonnet-4",
1117+ "kiro-claude-sonnet-4-20250514": "claude-sonnet-4",
1118+ "kiro-claude-haiku-4-5": "claude-haiku-4.5",
1119+ "kiro-auto": "auto",
1120+ // Native format (no prefix) - used by Kiro IDE directly
1121+ "claude-opus-4-5": "claude-opus-4.5",
1122+ "claude-opus-4.5": "claude-opus-4.5",
1123+ "claude-haiku-4-5": "claude-haiku-4.5",
1124+ "claude-haiku-4.5": "claude-haiku-4.5",
1125+ "claude-sonnet-4-5": "claude-sonnet-4.5",
1126+ "claude-sonnet-4-5-20250929": "claude-sonnet-4.5",
1127+ "claude-sonnet-4.5": "claude-sonnet-4.5",
1128+ "claude-sonnet-4": "claude-sonnet-4",
1129+ "claude-sonnet-4-20250514": "claude-sonnet-4",
1130+ "auto": "auto",
1131+ // Agentic variants (same backend model IDs, but with special system prompt)
1132+ "claude-opus-4.5-agentic": "claude-opus-4.5",
1133+ "claude-sonnet-4.5-agentic": "claude-sonnet-4.5",
1134+ "claude-sonnet-4-agentic": "claude-sonnet-4",
1135+ "claude-haiku-4.5-agentic": "claude-haiku-4.5",
1136+ "kiro-claude-opus-4-5-agentic": "claude-opus-4.5",
1137+ "kiro-claude-sonnet-4-5-agentic": "claude-sonnet-4.5",
1138+ "kiro-claude-sonnet-4-agentic": "claude-sonnet-4",
1139+ "kiro-claude-haiku-4-5-agentic": "claude-haiku-4.5",
1140+ }
1141+ if kiroID, ok := modelMap[model]; ok {
1142+ return kiroID
1143+ }
1144+
1145+ // Smart fallback: try to infer model type from name patterns
1146+ modelLower := strings.ToLower(model)
1147+
1148+ // Check for Haiku variants
1149+ if strings.Contains(modelLower, "haiku") {
1150+ log.Debugf("kiro: unknown Haiku model '%s', mapping to claude-haiku-4.5", model)
1151+ return "claude-haiku-4.5"
1152+ }
1153+
1154+ // Check for Sonnet variants
1155+ if strings.Contains(modelLower, "sonnet") {
1156+ // Check for specific version patterns
1157+ if strings.Contains(modelLower, "3-7") || strings.Contains(modelLower, "3.7") {
1158+ log.Debugf("kiro: unknown Sonnet 3.7 model '%s', mapping to claude-3-7-sonnet-20250219", model)
1159+ return "claude-3-7-sonnet-20250219"
1160+ }
1161+ if strings.Contains(modelLower, "4-5") || strings.Contains(modelLower, "4.5") {
1162+ log.Debugf("kiro: unknown Sonnet 4.5 model '%s', mapping to claude-sonnet-4.5", model)
1163+ return "claude-sonnet-4.5"
1164+ }
1165+ // Default to Sonnet 4
1166+ log.Debugf("kiro: unknown Sonnet model '%s', mapping to claude-sonnet-4", model)
1167+ return "claude-sonnet-4"
1168+ }
1169+
1170+ // Check for Opus variants
1171+ if strings.Contains(modelLower, "opus") {
1172+ log.Debugf("kiro: unknown Opus model '%s', mapping to claude-opus-4.5", model)
1173+ return "claude-opus-4.5"
1174+ }
1175+
1176+ // Final fallback to Sonnet 4.5 (most commonly used model)
1177+ log.Warnf("kiro: unknown model '%s', falling back to claude-sonnet-4.5", model)
1178+ return "claude-sonnet-4.5"
1179+}
1180+
1181+// EventStreamError represents an Event Stream processing error
1182+type EventStreamError struct {
1183+ Type string // "fatal", "malformed"
1184+ Message string
1185+ Cause error
1186+}
1187+
1188+func (e *EventStreamError) Error() string {
1189+ if e.Cause != nil {
1190+ return fmt.Sprintf("event stream %s: %s: %v", e.Type, e.Message, e.Cause)
1191+ }
1192+ return fmt.Sprintf("event stream %s: %s", e.Type, e.Message)
1193+}
1194+
1195+// eventStreamMessage represents a parsed AWS Event Stream message
1196+type eventStreamMessage struct {
1197+ EventType string // Event type from headers (e.g., "assistantResponseEvent")
1198+ Payload []byte // JSON payload of the message
1199+}
1200+
1201+// NOTE: Request building functions moved to internal/translator/kiro/claude/kiro_claude_request.go
1202+// The executor now uses kiroclaude.BuildKiroPayload() instead
1203+
1204+// parseEventStream parses AWS Event Stream binary format.
1205+// Extracts text content, tool uses, and stop_reason from the response.
1206+// Supports embedded [Called ...] tool calls and input buffering for toolUseEvent.
1207+// Returns: content, toolUses, usageInfo, stopReason, error
1208+func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroclaude.KiroToolUse, usage.Detail, string, error) {
1209+ var content strings.Builder
1210+ var toolUses []kiroclaude.KiroToolUse
1211+ var usageInfo usage.Detail
1212+ var stopReason string // Extracted from upstream response
1213+ reader := bufio.NewReader(body)
1214+
1215+ // Tool use state tracking for input buffering and deduplication
1216+ processedIDs := make(map[string]bool)
1217+ var currentToolUse *kiroclaude.ToolUseState
1218+
1219+ // Upstream usage tracking - Kiro API returns credit usage and context percentage
1220+ var upstreamContextPercentage float64 // Context usage percentage from upstream (e.g., 78.56)
1221+
1222+ for {
1223+ msg, eventErr := e.readEventStreamMessage(reader)
1224+ if eventErr != nil {
1225+ log.Errorf("kiro: parseEventStream error: %v", eventErr)
1226+ return content.String(), toolUses, usageInfo, stopReason, eventErr
1227+ }
1228+ if msg == nil {
1229+ // Normal end of stream (EOF)
1230+ break
1231+ }
1232+
1233+ eventType := msg.EventType
1234+ payload := msg.Payload
1235+ if len(payload) == 0 {
1236+ continue
1237+ }
1238+
1239+ var event map[string]interface{}
1240+ if err := json.Unmarshal(payload, &event); err != nil {
1241+ log.Debugf("kiro: skipping malformed event: %v", err)
1242+ continue
1243+ }
1244+
1245+ // Check for error/exception events in the payload (Kiro API may return errors with HTTP 200)
1246+ // These can appear as top-level fields or nested within the event
1247+ if errType, hasErrType := event["_type"].(string); hasErrType {
1248+ // AWS-style error: {"_type": "com.amazon.aws.codewhisperer#ValidationException", "message": "..."}
1249+ errMsg := ""
1250+ if msg, ok := event["message"].(string); ok {
1251+ errMsg = msg
1252+ }
1253+ log.Errorf("kiro: received AWS error in event stream: type=%s, message=%s", errType, errMsg)
1254+ return "", nil, usageInfo, stopReason, fmt.Errorf("kiro API error: %s - %s", errType, errMsg)
1255+ }
1256+ if errType, hasErrType := event["type"].(string); hasErrType && (errType == "error" || errType == "exception") {
1257+ // Generic error event
1258+ errMsg := ""
1259+ if msg, ok := event["message"].(string); ok {
1260+ errMsg = msg
1261+ } else if errObj, ok := event["error"].(map[string]interface{}); ok {
1262+ if msg, ok := errObj["message"].(string); ok {
1263+ errMsg = msg
1264+ }
1265+ }
1266+ log.Errorf("kiro: received error event in stream: type=%s, message=%s", errType, errMsg)
1267+ return "", nil, usageInfo, stopReason, fmt.Errorf("kiro API error: %s", errMsg)
1268+ }
1269+
1270+ // Extract stop_reason from various event formats
1271+ // Kiro/Amazon Q API may include stop_reason in different locations
1272+ if sr := kirocommon.GetString(event, "stop_reason"); sr != "" {
1273+ stopReason = sr
1274+ log.Debugf("kiro: parseEventStream found stop_reason (top-level): %s", stopReason)
1275+ }
1276+ if sr := kirocommon.GetString(event, "stopReason"); sr != "" {
1277+ stopReason = sr
1278+ log.Debugf("kiro: parseEventStream found stopReason (top-level): %s", stopReason)
1279+ }
1280+
1281+ // Handle different event types
1282+ switch eventType {
1283+ case "followupPromptEvent":
1284+ // Filter out followupPrompt events - these are UI suggestions, not content
1285+ log.Debugf("kiro: parseEventStream ignoring followupPrompt event")
1286+ continue
1287+
1288+ case "assistantResponseEvent":
1289+ if assistantResp, ok := event["assistantResponseEvent"].(map[string]interface{}); ok {
1290+ if contentText, ok := assistantResp["content"].(string); ok {
1291+ content.WriteString(contentText)
1292+ }
1293+ // Extract stop_reason from assistantResponseEvent
1294+ if sr := kirocommon.GetString(assistantResp, "stop_reason"); sr != "" {
1295+ stopReason = sr
1296+ log.Debugf("kiro: parseEventStream found stop_reason in assistantResponseEvent: %s", stopReason)
1297+ }
1298+ if sr := kirocommon.GetString(assistantResp, "stopReason"); sr != "" {
1299+ stopReason = sr
1300+ log.Debugf("kiro: parseEventStream found stopReason in assistantResponseEvent: %s", stopReason)
1301+ }
1302+ // Extract tool uses from response
1303+ if toolUsesRaw, ok := assistantResp["toolUses"].([]interface{}); ok {
1304+ for _, tuRaw := range toolUsesRaw {
1305+ if tu, ok := tuRaw.(map[string]interface{}); ok {
1306+ toolUseID := kirocommon.GetStringValue(tu, "toolUseId")
1307+ // Check for duplicate
1308+ if processedIDs[toolUseID] {
1309+ log.Debugf("kiro: skipping duplicate tool use from assistantResponse: %s", toolUseID)
1310+ continue
1311+ }
1312+ processedIDs[toolUseID] = true
1313+
1314+ toolUse := kiroclaude.KiroToolUse{
1315+ ToolUseID: toolUseID,
1316+ Name: kirocommon.GetStringValue(tu, "name"),
1317+ }
1318+ if input, ok := tu["input"].(map[string]interface{}); ok {
1319+ toolUse.Input = input
1320+ }
1321+ toolUses = append(toolUses, toolUse)
1322+ }
1323+ }
1324+ }
1325+ }
1326+ // Also try direct format
1327+ if contentText, ok := event["content"].(string); ok {
1328+ content.WriteString(contentText)
1329+ }
1330+ // Direct tool uses
1331+ if toolUsesRaw, ok := event["toolUses"].([]interface{}); ok {
1332+ for _, tuRaw := range toolUsesRaw {
1333+ if tu, ok := tuRaw.(map[string]interface{}); ok {
1334+ toolUseID := kirocommon.GetStringValue(tu, "toolUseId")
1335+ // Check for duplicate
1336+ if processedIDs[toolUseID] {
1337+ log.Debugf("kiro: skipping duplicate direct tool use: %s", toolUseID)
1338+ continue
1339+ }
1340+ processedIDs[toolUseID] = true
1341+
1342+ toolUse := kiroclaude.KiroToolUse{
1343+ ToolUseID: toolUseID,
1344+ Name: kirocommon.GetStringValue(tu, "name"),
1345+ }
1346+ if input, ok := tu["input"].(map[string]interface{}); ok {
1347+ toolUse.Input = input
1348+ }
1349+ toolUses = append(toolUses, toolUse)
1350+ }
1351+ }
1352+ }
1353+
1354+ case "toolUseEvent":
1355+ // Handle dedicated tool use events with input buffering
1356+ completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs)
1357+ currentToolUse = newState
1358+ toolUses = append(toolUses, completedToolUses...)
1359+
1360+ case "supplementaryWebLinksEvent":
1361+ if inputTokens, ok := event["inputTokens"].(float64); ok {
1362+ usageInfo.InputTokens = int64(inputTokens)
1363+ }
1364+ if outputTokens, ok := event["outputTokens"].(float64); ok {
1365+ usageInfo.OutputTokens = int64(outputTokens)
1366+ }
1367+
1368+ case "messageStopEvent", "message_stop":
1369+ // Handle message stop events which may contain stop_reason
1370+ if sr := kirocommon.GetString(event, "stop_reason"); sr != "" {
1371+ stopReason = sr
1372+ log.Debugf("kiro: parseEventStream found stop_reason in messageStopEvent: %s", stopReason)
1373+ }
1374+ if sr := kirocommon.GetString(event, "stopReason"); sr != "" {
1375+ stopReason = sr
1376+ log.Debugf("kiro: parseEventStream found stopReason in messageStopEvent: %s", stopReason)
1377+ }
1378+
1379+ case "messageMetadataEvent", "metadataEvent":
1380+ // Handle message metadata events which contain token counts
1381+ // Official format: { tokenUsage: { outputTokens, totalTokens, uncachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens, contextUsagePercentage } }
1382+ var metadata map[string]interface{}
1383+ if m, ok := event["messageMetadataEvent"].(map[string]interface{}); ok {
1384+ metadata = m
1385+ } else if m, ok := event["metadataEvent"].(map[string]interface{}); ok {
1386+ metadata = m
1387+ } else {
1388+ metadata = event // event itself might be the metadata
1389+ }
1390+
1391+ // Check for nested tokenUsage object (official format)
1392+ if tokenUsage, ok := metadata["tokenUsage"].(map[string]interface{}); ok {
1393+ // outputTokens - precise output token count
1394+ if outputTokens, ok := tokenUsage["outputTokens"].(float64); ok {
1395+ usageInfo.OutputTokens = int64(outputTokens)
1396+ log.Infof("kiro: parseEventStream found precise outputTokens in tokenUsage: %d", usageInfo.OutputTokens)
1397+ }
1398+ // totalTokens - precise total token count
1399+ if totalTokens, ok := tokenUsage["totalTokens"].(float64); ok {
1400+ usageInfo.TotalTokens = int64(totalTokens)
1401+ log.Infof("kiro: parseEventStream found precise totalTokens in tokenUsage: %d", usageInfo.TotalTokens)
1402+ }
1403+ // uncachedInputTokens - input tokens not from cache
1404+ if uncachedInputTokens, ok := tokenUsage["uncachedInputTokens"].(float64); ok {
1405+ usageInfo.InputTokens = int64(uncachedInputTokens)
1406+ log.Infof("kiro: parseEventStream found uncachedInputTokens in tokenUsage: %d", usageInfo.InputTokens)
1407+ }
1408+ // cacheReadInputTokens - tokens read from cache
1409+ if cacheReadTokens, ok := tokenUsage["cacheReadInputTokens"].(float64); ok {
1410+ // Add to input tokens if we have uncached tokens, otherwise use as input
1411+ if usageInfo.InputTokens > 0 {
1412+ usageInfo.InputTokens += int64(cacheReadTokens)
1413+ } else {
1414+ usageInfo.InputTokens = int64(cacheReadTokens)
1415+ }
1416+ log.Debugf("kiro: parseEventStream found cacheReadInputTokens in tokenUsage: %d", int64(cacheReadTokens))
1417+ }
1418+ // contextUsagePercentage - can be used as fallback for input token estimation
1419+ if ctxPct, ok := tokenUsage["contextUsagePercentage"].(float64); ok {
1420+ upstreamContextPercentage = ctxPct
1421+ log.Debugf("kiro: parseEventStream found contextUsagePercentage in tokenUsage: %.2f%%", ctxPct)
1422+ }
1423+ }
1424+
1425+ // Fallback: check for direct fields in metadata (legacy format)
1426+ if usageInfo.InputTokens == 0 {
1427+ if inputTokens, ok := metadata["inputTokens"].(float64); ok {
1428+ usageInfo.InputTokens = int64(inputTokens)
1429+ log.Debugf("kiro: parseEventStream found inputTokens in messageMetadataEvent: %d", usageInfo.InputTokens)
1430+ }
1431+ }
1432+ if usageInfo.OutputTokens == 0 {
1433+ if outputTokens, ok := metadata["outputTokens"].(float64); ok {
1434+ usageInfo.OutputTokens = int64(outputTokens)
1435+ log.Debugf("kiro: parseEventStream found outputTokens in messageMetadataEvent: %d", usageInfo.OutputTokens)
1436+ }
1437+ }
1438+ if usageInfo.TotalTokens == 0 {
1439+ if totalTokens, ok := metadata["totalTokens"].(float64); ok {
1440+ usageInfo.TotalTokens = int64(totalTokens)
1441+ log.Debugf("kiro: parseEventStream found totalTokens in messageMetadataEvent: %d", usageInfo.TotalTokens)
1442+ }
1443+ }
1444+
1445+ case "usageEvent", "usage":
1446+ // Handle dedicated usage events
1447+ if inputTokens, ok := event["inputTokens"].(float64); ok {
1448+ usageInfo.InputTokens = int64(inputTokens)
1449+ log.Debugf("kiro: parseEventStream found inputTokens in usageEvent: %d", usageInfo.InputTokens)
1450+ }
1451+ if outputTokens, ok := event["outputTokens"].(float64); ok {
1452+ usageInfo.OutputTokens = int64(outputTokens)
1453+ log.Debugf("kiro: parseEventStream found outputTokens in usageEvent: %d", usageInfo.OutputTokens)
1454+ }
1455+ if totalTokens, ok := event["totalTokens"].(float64); ok {
1456+ usageInfo.TotalTokens = int64(totalTokens)
1457+ log.Debugf("kiro: parseEventStream found totalTokens in usageEvent: %d", usageInfo.TotalTokens)
1458+ }
1459+ // Also check nested usage object
1460+ if usageObj, ok := event["usage"].(map[string]interface{}); ok {
1461+ if inputTokens, ok := usageObj["input_tokens"].(float64); ok {
1462+ usageInfo.InputTokens = int64(inputTokens)
1463+ } else if inputTokens, ok := usageObj["prompt_tokens"].(float64); ok {
1464+ usageInfo.InputTokens = int64(inputTokens)
1465+ }
1466+ if outputTokens, ok := usageObj["output_tokens"].(float64); ok {
1467+ usageInfo.OutputTokens = int64(outputTokens)
1468+ } else if outputTokens, ok := usageObj["completion_tokens"].(float64); ok {
1469+ usageInfo.OutputTokens = int64(outputTokens)
1470+ }
1471+ if totalTokens, ok := usageObj["total_tokens"].(float64); ok {
1472+ usageInfo.TotalTokens = int64(totalTokens)
1473+ }
1474+ log.Debugf("kiro: parseEventStream found usage object: input=%d, output=%d, total=%d",
1475+ usageInfo.InputTokens, usageInfo.OutputTokens, usageInfo.TotalTokens)
1476+ }
1477+
1478+ case "metricsEvent":
1479+ // Handle metrics events which may contain usage data
1480+ if metrics, ok := event["metricsEvent"].(map[string]interface{}); ok {
1481+ if inputTokens, ok := metrics["inputTokens"].(float64); ok {
1482+ usageInfo.InputTokens = int64(inputTokens)
1483+ }
1484+ if outputTokens, ok := metrics["outputTokens"].(float64); ok {
1485+ usageInfo.OutputTokens = int64(outputTokens)
1486+ }
1487+ log.Debugf("kiro: parseEventStream found metricsEvent: input=%d, output=%d",
1488+ usageInfo.InputTokens, usageInfo.OutputTokens)
1489+ }
1490+
1491+ case "meteringEvent":
1492+ // Handle metering events from Kiro API (usage billing information)
1493+ // Official format: { unit: string, unitPlural: string, usage: number }
1494+ if metering, ok := event["meteringEvent"].(map[string]interface{}); ok {
1495+ unit := ""
1496+ if u, ok := metering["unit"].(string); ok {
1497+ unit = u
1498+ }
1499+ usageVal := 0.0
1500+ if u, ok := metering["usage"].(float64); ok {
1501+ usageVal = u
1502+ }
1503+ log.Infof("kiro: parseEventStream received meteringEvent: usage=%.2f %s", usageVal, unit)
1504+ // Store metering info for potential billing/statistics purposes
1505+ // Note: This is separate from token counts - it's AWS billing units
1506+ } else {
1507+ // Try direct fields
1508+ unit := ""
1509+ if u, ok := event["unit"].(string); ok {
1510+ unit = u
1511+ }
1512+ usageVal := 0.0
1513+ if u, ok := event["usage"].(float64); ok {
1514+ usageVal = u
1515+ }
1516+ if unit != "" || usageVal > 0 {
1517+ log.Infof("kiro: parseEventStream received meteringEvent (direct): usage=%.2f %s", usageVal, unit)
1518+ }
1519+ }
1520+
1521+ case "error", "exception", "internalServerException", "invalidStateEvent":
1522+ // Handle error events from Kiro API stream
1523+ errMsg := ""
1524+ errType := eventType
1525+
1526+ // Try to extract error message from various formats
1527+ if msg, ok := event["message"].(string); ok {
1528+ errMsg = msg
1529+ } else if errObj, ok := event[eventType].(map[string]interface{}); ok {
1530+ if msg, ok := errObj["message"].(string); ok {
1531+ errMsg = msg
1532+ }
1533+ if t, ok := errObj["type"].(string); ok {
1534+ errType = t
1535+ }
1536+ } else if errObj, ok := event["error"].(map[string]interface{}); ok {
1537+ if msg, ok := errObj["message"].(string); ok {
1538+ errMsg = msg
1539+ }
1540+ if t, ok := errObj["type"].(string); ok {
1541+ errType = t
1542+ }
1543+ }
1544+
1545+ // Check for specific error reasons
1546+ if reason, ok := event["reason"].(string); ok {
1547+ errMsg = fmt.Sprintf("%s (reason: %s)", errMsg, reason)
1548+ }
1549+
1550+ log.Errorf("kiro: parseEventStream received error event: type=%s, message=%s", errType, errMsg)
1551+
1552+ // For invalidStateEvent, we may want to continue processing other events
1553+ if eventType == "invalidStateEvent" {
1554+ log.Warnf("kiro: invalidStateEvent received, continuing stream processing")
1555+ continue
1556+ }
1557+
1558+ // For other errors, return the error
1559+ if errMsg != "" {
1560+ return "", nil, usageInfo, stopReason, fmt.Errorf("kiro API error (%s): %s", errType, errMsg)
1561+ }
1562+
1563+ default:
1564+ // Check for contextUsagePercentage in any event
1565+ if ctxPct, ok := event["contextUsagePercentage"].(float64); ok {
1566+ upstreamContextPercentage = ctxPct
1567+ log.Debugf("kiro: parseEventStream received context usage: %.2f%%", upstreamContextPercentage)
1568+ }
1569+ // Log unknown event types for debugging (to discover new event formats)
1570+ log.Debugf("kiro: parseEventStream unknown event type: %s, payload: %s", eventType, string(payload))
1571+ }
1572+
1573+ // Check for direct token fields in any event (fallback)
1574+ if usageInfo.InputTokens == 0 {
1575+ if inputTokens, ok := event["inputTokens"].(float64); ok {
1576+ usageInfo.InputTokens = int64(inputTokens)
1577+ log.Debugf("kiro: parseEventStream found direct inputTokens: %d", usageInfo.InputTokens)
1578+ }
1579+ }
1580+ if usageInfo.OutputTokens == 0 {
1581+ if outputTokens, ok := event["outputTokens"].(float64); ok {
1582+ usageInfo.OutputTokens = int64(outputTokens)
1583+ log.Debugf("kiro: parseEventStream found direct outputTokens: %d", usageInfo.OutputTokens)
1584+ }
1585+ }
1586+
1587+ // Check for usage object in any event (OpenAI format)
1588+ if usageInfo.InputTokens == 0 || usageInfo.OutputTokens == 0 {
1589+ if usageObj, ok := event["usage"].(map[string]interface{}); ok {
1590+ if usageInfo.InputTokens == 0 {
1591+ if inputTokens, ok := usageObj["input_tokens"].(float64); ok {
1592+ usageInfo.InputTokens = int64(inputTokens)
1593+ } else if inputTokens, ok := usageObj["prompt_tokens"].(float64); ok {
1594+ usageInfo.InputTokens = int64(inputTokens)
1595+ }
1596+ }
1597+ if usageInfo.OutputTokens == 0 {
1598+ if outputTokens, ok := usageObj["output_tokens"].(float64); ok {
1599+ usageInfo.OutputTokens = int64(outputTokens)
1600+ } else if outputTokens, ok := usageObj["completion_tokens"].(float64); ok {
1601+ usageInfo.OutputTokens = int64(outputTokens)
1602+ }
1603+ }
1604+ if usageInfo.TotalTokens == 0 {
1605+ if totalTokens, ok := usageObj["total_tokens"].(float64); ok {
1606+ usageInfo.TotalTokens = int64(totalTokens)
1607+ }
1608+ }
1609+ log.Debugf("kiro: parseEventStream found usage object (fallback): input=%d, output=%d, total=%d",
1610+ usageInfo.InputTokens, usageInfo.OutputTokens, usageInfo.TotalTokens)
1611+ }
1612+ }
1613+
1614+ // Also check nested supplementaryWebLinksEvent
1615+ if usageEvent, ok := event["supplementaryWebLinksEvent"].(map[string]interface{}); ok {
1616+ if inputTokens, ok := usageEvent["inputTokens"].(float64); ok {
1617+ usageInfo.InputTokens = int64(inputTokens)
1618+ }
1619+ if outputTokens, ok := usageEvent["outputTokens"].(float64); ok {
1620+ usageInfo.OutputTokens = int64(outputTokens)
1621+ }
1622+ }
1623+ }
1624+
1625+ // Parse embedded tool calls from content (e.g., [Called tool_name with args: {...}])
1626+ contentStr := content.String()
1627+ cleanedContent, embeddedToolUses := kiroclaude.ParseEmbeddedToolCalls(contentStr, processedIDs)
1628+ toolUses = append(toolUses, embeddedToolUses...)
1629+
1630+ // Deduplicate all tool uses
1631+ toolUses = kiroclaude.DeduplicateToolUses(toolUses)
1632+
1633+ // Apply fallback logic for stop_reason if not provided by upstream
1634+ // Priority: upstream stopReason > tool_use detection > end_turn default
1635+ if stopReason == "" {
1636+ if len(toolUses) > 0 {
1637+ stopReason = "tool_use"
1638+ log.Debugf("kiro: parseEventStream using fallback stop_reason: tool_use (detected %d tool uses)", len(toolUses))
1639+ } else {
1640+ stopReason = "end_turn"
1641+ log.Debugf("kiro: parseEventStream using fallback stop_reason: end_turn")
1642+ }
1643+ }
1644+
1645+ // Log warning if response was truncated due to max_tokens
1646+ if stopReason == "max_tokens" {
1647+ log.Warnf("kiro: response truncated due to max_tokens limit")
1648+ }
1649+
1650+ // Use contextUsagePercentage to calculate more accurate input tokens
1651+ // Kiro model has 200k max context, contextUsagePercentage represents the percentage used
1652+ // Formula: input_tokens = contextUsagePercentage * 200000 / 100
1653+ if upstreamContextPercentage > 0 {
1654+ calculatedInputTokens := int64(upstreamContextPercentage * 200000 / 100)
1655+ if calculatedInputTokens > 0 {
1656+ localEstimate := usageInfo.InputTokens
1657+ usageInfo.InputTokens = calculatedInputTokens
1658+ usageInfo.TotalTokens = usageInfo.InputTokens + usageInfo.OutputTokens
1659+ log.Infof("kiro: parseEventStream using contextUsagePercentage (%.2f%%) to calculate input tokens: %d (local estimate was: %d)",
1660+ upstreamContextPercentage, calculatedInputTokens, localEstimate)
1661+ }
1662+ }
1663+
1664+ return cleanedContent, toolUses, usageInfo, stopReason, nil
1665+}
1666+
1667+// readEventStreamMessage reads and validates a single AWS Event Stream message.
1668+// Returns the parsed message or a structured error for different failure modes.
1669+// This function implements boundary protection and detailed error classification.
1670+//
1671+// AWS Event Stream binary format:
1672+// - Prelude (12 bytes): total_length (4) + headers_length (4) + prelude_crc (4)
1673+// - Headers (variable): header entries
1674+// - Payload (variable): JSON data
1675+// - Message CRC (4 bytes): CRC32C of entire message (not validated, just skipped)
1676+func (e *KiroExecutor) readEventStreamMessage(reader *bufio.Reader) (*eventStreamMessage, *EventStreamError) {
1677+ // Read prelude (first 12 bytes: total_len + headers_len + prelude_crc)
1678+ prelude := make([]byte, 12)
1679+ _, err := io.ReadFull(reader, prelude)
1680+ if err == io.EOF {
1681+ return nil, nil // Normal end of stream
1682+ }
1683+ if err != nil {
1684+ return nil, &EventStreamError{
1685+ Type: ErrStreamFatal,
1686+ Message: "failed to read prelude",
1687+ Cause: err,
1688+ }
1689+ }
1690+
1691+ totalLength := binary.BigEndian.Uint32(prelude[0:4])
1692+ headersLength := binary.BigEndian.Uint32(prelude[4:8])
1693+ // Note: prelude[8:12] is prelude_crc - we read it but don't validate (no CRC check per requirements)
1694+
1695+ // Boundary check: minimum frame size
1696+ if totalLength < minEventStreamFrameSize {
1697+ return nil, &EventStreamError{
1698+ Type: ErrStreamMalformed,
1699+ Message: fmt.Sprintf("invalid message length: %d (minimum is %d)", totalLength, minEventStreamFrameSize),
1700+ }
1701+ }
1702+
1703+ // Boundary check: maximum message size
1704+ if totalLength > maxEventStreamMsgSize {
1705+ return nil, &EventStreamError{
1706+ Type: ErrStreamMalformed,
1707+ Message: fmt.Sprintf("message too large: %d bytes (maximum is %d)", totalLength, maxEventStreamMsgSize),
1708+ }
1709+ }
1710+
1711+ // Boundary check: headers length within message bounds
1712+ // Message structure: prelude(12) + headers(headersLength) + payload + message_crc(4)
1713+ // So: headersLength must be <= totalLength - 16 (12 for prelude + 4 for message_crc)
1714+ if headersLength > totalLength-16 {
1715+ return nil, &EventStreamError{
1716+ Type: ErrStreamMalformed,
1717+ Message: fmt.Sprintf("headers length %d exceeds message bounds (total: %d)", headersLength, totalLength),
1718+ }
1719+ }
1720+
1721+ // Read the rest of the message (total - 12 bytes already read)
1722+ remaining := make([]byte, totalLength-12)
1723+ _, err = io.ReadFull(reader, remaining)
1724+ if err != nil {
1725+ return nil, &EventStreamError{
1726+ Type: ErrStreamFatal,
1727+ Message: "failed to read message body",
1728+ Cause: err,
1729+ }
1730+ }
1731+
1732+ // Extract event type from headers
1733+ // Headers start at beginning of 'remaining', length is headersLength
1734+ var eventType string
1735+ if headersLength > 0 && headersLength <= uint32(len(remaining)) {
1736+ eventType = e.extractEventTypeFromBytes(remaining[:headersLength])
1737+ }
1738+
1739+ // Calculate payload boundaries
1740+ // Payload starts after headers, ends before message_crc (last 4 bytes)
1741+ payloadStart := headersLength
1742+ payloadEnd := uint32(len(remaining)) - 4 // Skip message_crc at end
1743+
1744+ // Validate payload boundaries
1745+ if payloadStart >= payloadEnd {
1746+ // No payload, return empty message
1747+ return &eventStreamMessage{
1748+ EventType: eventType,
1749+ Payload: nil,
1750+ }, nil
1751+ }
1752+
1753+ payload := remaining[payloadStart:payloadEnd]
1754+
1755+ return &eventStreamMessage{
1756+ EventType: eventType,
1757+ Payload: payload,
1758+ }, nil
1759+}
1760+
1761+func skipEventStreamHeaderValue(headers []byte, offset int, valueType byte) (int, bool) {
1762+ switch valueType {
1763+ case 0, 1: // bool true / bool false
1764+ return offset, true
1765+ case 2: // byte
1766+ if offset+1 > len(headers) {
1767+ return offset, false
1768+ }
1769+ return offset + 1, true
1770+ case 3: // short
1771+ if offset+2 > len(headers) {
1772+ return offset, false
1773+ }
1774+ return offset + 2, true
1775+ case 4: // int
1776+ if offset+4 > len(headers) {
1777+ return offset, false
1778+ }
1779+ return offset + 4, true
1780+ case 5: // long
1781+ if offset+8 > len(headers) {
1782+ return offset, false
1783+ }
1784+ return offset + 8, true
1785+ case 6: // byte array (2-byte length + data)
1786+ if offset+2 > len(headers) {
1787+ return offset, false
1788+ }
1789+ valueLen := int(binary.BigEndian.Uint16(headers[offset : offset+2]))
1790+ offset += 2
1791+ if offset+valueLen > len(headers) {
1792+ return offset, false
1793+ }
1794+ return offset + valueLen, true
1795+ case 8: // timestamp
1796+ if offset+8 > len(headers) {
1797+ return offset, false
1798+ }
1799+ return offset + 8, true
1800+ case 9: // uuid
1801+ if offset+16 > len(headers) {
1802+ return offset, false
1803+ }
1804+ return offset + 16, true
1805+ default:
1806+ return offset, false
1807+ }
1808+}
1809+
1810+// extractEventTypeFromBytes extracts the event type from raw header bytes (without prelude CRC prefix)
1811+func (e *KiroExecutor) extractEventTypeFromBytes(headers []byte) string {
1812+ offset := 0
1813+ for offset < len(headers) {
1814+ nameLen := int(headers[offset])
1815+ offset++
1816+ if offset+nameLen > len(headers) {
1817+ break
1818+ }
1819+ name := string(headers[offset : offset+nameLen])
1820+ offset += nameLen
1821+
1822+ if offset >= len(headers) {
1823+ break
1824+ }
1825+ valueType := headers[offset]
1826+ offset++
1827+
1828+ if valueType == 7 { // String type
1829+ if offset+2 > len(headers) {
1830+ break
1831+ }
1832+ valueLen := int(binary.BigEndian.Uint16(headers[offset : offset+2]))
1833+ offset += 2
1834+ if offset+valueLen > len(headers) {
1835+ break
1836+ }
1837+ value := string(headers[offset : offset+valueLen])
1838+ offset += valueLen
1839+
1840+ if name == ":event-type" {
1841+ return value
1842+ }
1843+ continue
1844+ }
1845+
1846+ nextOffset, ok := skipEventStreamHeaderValue(headers, offset, valueType)
1847+ if !ok {
1848+ break
1849+ }
1850+ offset = nextOffset
1851+ }
1852+ return ""
1853+}
1854+
1855+
1856+// NOTE: Response building functions moved to internal/translator/kiro/claude/kiro_claude_response.go
1857+// The executor now uses kiroclaude.BuildClaudeResponse() and kiroclaude.ExtractThinkingFromContent() instead
1858+
1859+// streamToChannel converts AWS Event Stream to channel-based streaming.
1860+// Supports tool calling - emits tool_use content blocks when tools are used.
1861+// Includes embedded [Called ...] tool call parsing and input buffering for toolUseEvent.
1862+// Implements duplicate content filtering using lastContentEvent detection (based on AIClient-2-API).
1863+// Extracts stop_reason from upstream events when available.
1864+// thinkingEnabled controls whether <thinking> tags are parsed - only parse when request enabled thinking.
1865+func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out chan<- cliproxyexecutor.StreamChunk, targetFormat sdktranslator.Format, model string, originalReq, claudeBody []byte, reporter *usageReporter, thinkingEnabled bool) {
1866+ reader := bufio.NewReaderSize(body, 20*1024*1024) // 20MB buffer to match other providers
1867+ var totalUsage usage.Detail
1868+ var hasToolUses bool // Track if any tool uses were emitted
1869+ var upstreamStopReason string // Track stop_reason from upstream events
1870+
1871+ // Tool use state tracking for input buffering and deduplication
1872+ processedIDs := make(map[string]bool)
1873+ var currentToolUse *kiroclaude.ToolUseState
1874+
1875+ // NOTE: Duplicate content filtering removed - it was causing legitimate repeated
1876+ // content (like consecutive newlines) to be incorrectly filtered out.
1877+ // The previous implementation compared lastContentEvent == contentDelta which
1878+ // is too aggressive for streaming scenarios.
1879+
1880+ // Streaming token calculation - accumulate content for real-time token counting
1881+ // Based on AIClient-2-API implementation
1882+ var accumulatedContent strings.Builder
1883+ accumulatedContent.Grow(4096) // Pre-allocate 4KB capacity to reduce reallocations
1884+
1885+ // Real-time usage estimation state
1886+ // These track when to send periodic usage updates during streaming
1887+ var lastUsageUpdateLen int // Last accumulated content length when usage was sent
1888+ var lastUsageUpdateTime = time.Now() // Last time usage update was sent
1889+ var lastReportedOutputTokens int64 // Last reported output token count
1890+
1891+ // Upstream usage tracking - Kiro API returns credit usage and context percentage
1892+ var upstreamCreditUsage float64 // Credit usage from upstream (e.g., 1.458)
1893+ var upstreamContextPercentage float64 // Context usage percentage from upstream (e.g., 78.56)
1894+ var hasUpstreamUsage bool // Whether we received usage from upstream
1895+
1896+ // Translator param for maintaining tool call state across streaming events
1897+ // IMPORTANT: This must persist across all TranslateStream calls
1898+ var translatorParam any
1899+
1900+ // Thinking mode state tracking - tag-based parsing for <thinking> tags in content
1901+ inThinkBlock := false // Whether we're currently inside a <thinking> block
1902+ isThinkingBlockOpen := false // Track if thinking content block SSE event is open
1903+ thinkingBlockIndex := -1 // Index of the thinking content block
1904+ var accumulatedThinkingContent strings.Builder // Accumulate thinking content for token counting
1905+
1906+ // Buffer for handling partial tag matches at chunk boundaries
1907+ var pendingContent strings.Builder // Buffer content that might be part of a tag
1908+
1909+ // Pre-calculate input tokens from request if possible
1910+ // Kiro uses Claude format, so try Claude format first, then OpenAI format, then fallback
1911+ if enc, err := getTokenizer(model); err == nil {
1912+ var inputTokens int64
1913+ var countMethod string
1914+
1915+ // Try Claude format first (Kiro uses Claude API format)
1916+ if inp, err := countClaudeChatTokens(enc, claudeBody); err == nil && inp > 0 {
1917+ inputTokens = inp
1918+ countMethod = "claude"
1919+ } else if inp, err := countOpenAIChatTokens(enc, originalReq); err == nil && inp > 0 {
1920+ // Fallback to OpenAI format (for OpenAI-compatible requests)
1921+ inputTokens = inp
1922+ countMethod = "openai"
1923+ } else {
1924+ // Final fallback: estimate from raw request size (roughly 4 chars per token)
1925+ inputTokens = int64(len(claudeBody) / 4)
1926+ if inputTokens == 0 && len(claudeBody) > 0 {
1927+ inputTokens = 1
1928+ }
1929+ countMethod = "estimate"
1930+ }
1931+
1932+ totalUsage.InputTokens = inputTokens
1933+ log.Debugf("kiro: streamToChannel pre-calculated input tokens: %d (method: %s, claude body: %d bytes, original req: %d bytes)",
1934+ totalUsage.InputTokens, countMethod, len(claudeBody), len(originalReq))
1935+ }
1936+
1937+ contentBlockIndex := -1
1938+ messageStartSent := false
1939+ isTextBlockOpen := false
1940+ var outputLen int
1941+
1942+ // Ensure usage is published even on early return
1943+ defer func() {
1944+ reporter.publish(ctx, totalUsage)
1945+ }()
1946+
1947+ for {
1948+ select {
1949+ case <-ctx.Done():
1950+ return
1951+ default:
1952+ }
1953+
1954+ msg, eventErr := e.readEventStreamMessage(reader)
1955+ if eventErr != nil {
1956+ // Log the error
1957+ log.Errorf("kiro: streamToChannel error: %v", eventErr)
1958+
1959+ // Send error to channel for client notification
1960+ out <- cliproxyexecutor.StreamChunk{Err: eventErr}
1961+ return
1962+ }
1963+ if msg == nil {
1964+ // Normal end of stream (EOF)
1965+ // Flush any incomplete tool use before ending stream
1966+ if currentToolUse != nil && !processedIDs[currentToolUse.ToolUseID] {
1967+ log.Warnf("kiro: flushing incomplete tool use at EOF: %s (ID: %s)", currentToolUse.Name, currentToolUse.ToolUseID)
1968+ fullInput := currentToolUse.InputBuffer.String()
1969+ repairedJSON := kiroclaude.RepairJSON(fullInput)
1970+ var finalInput map[string]interface{}
1971+ if err := json.Unmarshal([]byte(repairedJSON), &finalInput); err != nil {
1972+ log.Warnf("kiro: failed to parse incomplete tool input at EOF: %v", err)
1973+ finalInput = make(map[string]interface{})
1974+ }
1975+
1976+ processedIDs[currentToolUse.ToolUseID] = true
1977+ contentBlockIndex++
1978+
1979+ // Send tool_use content block
1980+ blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", currentToolUse.ToolUseID, currentToolUse.Name)
1981+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
1982+ for _, chunk := range sseData {
1983+ if chunk != "" {
1984+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
1985+ }
1986+ }
1987+
1988+ // Send tool input as delta
1989+ inputBytes, _ := json.Marshal(finalInput)
1990+ inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputBytes), contentBlockIndex)
1991+ sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
1992+ for _, chunk := range sseData {
1993+ if chunk != "" {
1994+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
1995+ }
1996+ }
1997+
1998+ // Close block
1999+ blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
2000+ sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2001+ for _, chunk := range sseData {
2002+ if chunk != "" {
2003+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2004+ }
2005+ }
2006+
2007+ hasToolUses = true
2008+ currentToolUse = nil
2009+ }
2010+
2011+ // DISABLED: Tag-based pending character flushing
2012+ // This code block was used for tag-based thinking detection which has been
2013+ // replaced by reasoningContentEvent handling. No pending tag chars to flush.
2014+ // Original code preserved in git history.
2015+ break
2016+ }
2017+
2018+ eventType := msg.EventType
2019+ payload := msg.Payload
2020+ if len(payload) == 0 {
2021+ continue
2022+ }
2023+ appendAPIResponseChunk(ctx, e.cfg, payload)
2024+
2025+ var event map[string]interface{}
2026+ if err := json.Unmarshal(payload, &event); err != nil {
2027+ log.Warnf("kiro: failed to unmarshal event payload: %v, raw: %s", err, string(payload))
2028+ continue
2029+ }
2030+
2031+ // Check for error/exception events in the payload (Kiro API may return errors with HTTP 200)
2032+ // These can appear as top-level fields or nested within the event
2033+ if errType, hasErrType := event["_type"].(string); hasErrType {
2034+ // AWS-style error: {"_type": "com.amazon.aws.codewhisperer#ValidationException", "message": "..."}
2035+ errMsg := ""
2036+ if msg, ok := event["message"].(string); ok {
2037+ errMsg = msg
2038+ }
2039+ log.Errorf("kiro: received AWS error in stream: type=%s, message=%s", errType, errMsg)
2040+ out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("kiro API error: %s - %s", errType, errMsg)}
2041+ return
2042+ }
2043+ if errType, hasErrType := event["type"].(string); hasErrType && (errType == "error" || errType == "exception") {
2044+ // Generic error event
2045+ errMsg := ""
2046+ if msg, ok := event["message"].(string); ok {
2047+ errMsg = msg
2048+ } else if errObj, ok := event["error"].(map[string]interface{}); ok {
2049+ if msg, ok := errObj["message"].(string); ok {
2050+ errMsg = msg
2051+ }
2052+ }
2053+ log.Errorf("kiro: received error event in stream: type=%s, message=%s", errType, errMsg)
2054+ out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("kiro API error: %s", errMsg)}
2055+ return
2056+ }
2057+
2058+ // Extract stop_reason from various event formats (streaming)
2059+ // Kiro/Amazon Q API may include stop_reason in different locations
2060+ if sr := kirocommon.GetString(event, "stop_reason"); sr != "" {
2061+ upstreamStopReason = sr
2062+ log.Debugf("kiro: streamToChannel found stop_reason (top-level): %s", upstreamStopReason)
2063+ }
2064+ if sr := kirocommon.GetString(event, "stopReason"); sr != "" {
2065+ upstreamStopReason = sr
2066+ log.Debugf("kiro: streamToChannel found stopReason (top-level): %s", upstreamStopReason)
2067+ }
2068+
2069+ // Send message_start on first event
2070+ if !messageStartSent {
2071+ msgStart := kiroclaude.BuildClaudeMessageStartEvent(model, totalUsage.InputTokens)
2072+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStart, &translatorParam)
2073+ for _, chunk := range sseData {
2074+ if chunk != "" {
2075+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2076+ }
2077+ }
2078+ messageStartSent = true
2079+ }
2080+
2081+ switch eventType {
2082+ case "followupPromptEvent":
2083+ // Filter out followupPrompt events - these are UI suggestions, not content
2084+ log.Debugf("kiro: streamToChannel ignoring followupPrompt event")
2085+ continue
2086+
2087+ case "messageStopEvent", "message_stop":
2088+ // Handle message stop events which may contain stop_reason
2089+ if sr := kirocommon.GetString(event, "stop_reason"); sr != "" {
2090+ upstreamStopReason = sr
2091+ log.Debugf("kiro: streamToChannel found stop_reason in messageStopEvent: %s", upstreamStopReason)
2092+ }
2093+ if sr := kirocommon.GetString(event, "stopReason"); sr != "" {
2094+ upstreamStopReason = sr
2095+ log.Debugf("kiro: streamToChannel found stopReason in messageStopEvent: %s", upstreamStopReason)
2096+ }
2097+
2098+ case "meteringEvent":
2099+ // Handle metering events from Kiro API (usage billing information)
2100+ // Official format: { unit: string, unitPlural: string, usage: number }
2101+ if metering, ok := event["meteringEvent"].(map[string]interface{}); ok {
2102+ unit := ""
2103+ if u, ok := metering["unit"].(string); ok {
2104+ unit = u
2105+ }
2106+ usageVal := 0.0
2107+ if u, ok := metering["usage"].(float64); ok {
2108+ usageVal = u
2109+ }
2110+ upstreamCreditUsage = usageVal
2111+ hasUpstreamUsage = true
2112+ log.Infof("kiro: streamToChannel received meteringEvent: usage=%.4f %s", usageVal, unit)
2113+ } else {
2114+ // Try direct fields (event is meteringEvent itself)
2115+ if unit, ok := event["unit"].(string); ok {
2116+ if usage, ok := event["usage"].(float64); ok {
2117+ upstreamCreditUsage = usage
2118+ hasUpstreamUsage = true
2119+ log.Infof("kiro: streamToChannel received meteringEvent (direct): usage=%.4f %s", usage, unit)
2120+ }
2121+ }
2122+ }
2123+
2124+ case "error", "exception", "internalServerException":
2125+ // Handle error events from Kiro API stream
2126+ errMsg := ""
2127+ errType := eventType
2128+
2129+ // Try to extract error message from various formats
2130+ if msg, ok := event["message"].(string); ok {
2131+ errMsg = msg
2132+ } else if errObj, ok := event[eventType].(map[string]interface{}); ok {
2133+ if msg, ok := errObj["message"].(string); ok {
2134+ errMsg = msg
2135+ }
2136+ if t, ok := errObj["type"].(string); ok {
2137+ errType = t
2138+ }
2139+ } else if errObj, ok := event["error"].(map[string]interface{}); ok {
2140+ if msg, ok := errObj["message"].(string); ok {
2141+ errMsg = msg
2142+ }
2143+ }
2144+
2145+ log.Errorf("kiro: streamToChannel received error event: type=%s, message=%s", errType, errMsg)
2146+
2147+ // Send error to the stream and exit
2148+ if errMsg != "" {
2149+ out <- cliproxyexecutor.StreamChunk{
2150+ Err: fmt.Errorf("kiro API error (%s): %s", errType, errMsg),
2151+ }
2152+ return
2153+ }
2154+
2155+ case "invalidStateEvent":
2156+ // Handle invalid state events - log and continue (non-fatal)
2157+ errMsg := ""
2158+ if msg, ok := event["message"].(string); ok {
2159+ errMsg = msg
2160+ } else if stateEvent, ok := event["invalidStateEvent"].(map[string]interface{}); ok {
2161+ if msg, ok := stateEvent["message"].(string); ok {
2162+ errMsg = msg
2163+ }
2164+ }
2165+ log.Warnf("kiro: streamToChannel received invalidStateEvent: %s, continuing", errMsg)
2166+ continue
2167+
2168+ default:
2169+ // Check for upstream usage events from Kiro API
2170+ // Format: {"unit":"credit","unitPlural":"credits","usage":1.458}
2171+ if unit, ok := event["unit"].(string); ok && unit == "credit" {
2172+ if usage, ok := event["usage"].(float64); ok {
2173+ upstreamCreditUsage = usage
2174+ hasUpstreamUsage = true
2175+ log.Debugf("kiro: received upstream credit usage: %.4f", upstreamCreditUsage)
2176+ }
2177+ }
2178+ // Format: {"contextUsagePercentage":78.56}
2179+ if ctxPct, ok := event["contextUsagePercentage"].(float64); ok {
2180+ upstreamContextPercentage = ctxPct
2181+ log.Debugf("kiro: received upstream context usage: %.2f%%", upstreamContextPercentage)
2182+ }
2183+
2184+ // Check for token counts in unknown events
2185+ if inputTokens, ok := event["inputTokens"].(float64); ok {
2186+ totalUsage.InputTokens = int64(inputTokens)
2187+ hasUpstreamUsage = true
2188+ log.Debugf("kiro: streamToChannel found inputTokens in event %s: %d", eventType, totalUsage.InputTokens)
2189+ }
2190+ if outputTokens, ok := event["outputTokens"].(float64); ok {
2191+ totalUsage.OutputTokens = int64(outputTokens)
2192+ hasUpstreamUsage = true
2193+ log.Debugf("kiro: streamToChannel found outputTokens in event %s: %d", eventType, totalUsage.OutputTokens)
2194+ }
2195+ if totalTokens, ok := event["totalTokens"].(float64); ok {
2196+ totalUsage.TotalTokens = int64(totalTokens)
2197+ log.Debugf("kiro: streamToChannel found totalTokens in event %s: %d", eventType, totalUsage.TotalTokens)
2198+ }
2199+
2200+ // Check for usage object in unknown events (OpenAI/Claude format)
2201+ if usageObj, ok := event["usage"].(map[string]interface{}); ok {
2202+ if inputTokens, ok := usageObj["input_tokens"].(float64); ok {
2203+ totalUsage.InputTokens = int64(inputTokens)
2204+ hasUpstreamUsage = true
2205+ } else if inputTokens, ok := usageObj["prompt_tokens"].(float64); ok {
2206+ totalUsage.InputTokens = int64(inputTokens)
2207+ hasUpstreamUsage = true
2208+ }
2209+ if outputTokens, ok := usageObj["output_tokens"].(float64); ok {
2210+ totalUsage.OutputTokens = int64(outputTokens)
2211+ hasUpstreamUsage = true
2212+ } else if outputTokens, ok := usageObj["completion_tokens"].(float64); ok {
2213+ totalUsage.OutputTokens = int64(outputTokens)
2214+ hasUpstreamUsage = true
2215+ }
2216+ if totalTokens, ok := usageObj["total_tokens"].(float64); ok {
2217+ totalUsage.TotalTokens = int64(totalTokens)
2218+ }
2219+ log.Debugf("kiro: streamToChannel found usage object in event %s: input=%d, output=%d, total=%d",
2220+ eventType, totalUsage.InputTokens, totalUsage.OutputTokens, totalUsage.TotalTokens)
2221+ }
2222+
2223+ // Log unknown event types for debugging (to discover new event formats)
2224+ if eventType != "" {
2225+ log.Debugf("kiro: streamToChannel unknown event type: %s, payload: %s", eventType, string(payload))
2226+ }
2227+
2228+ case "assistantResponseEvent":
2229+ var contentDelta string
2230+ var toolUses []map[string]interface{}
2231+
2232+ if assistantResp, ok := event["assistantResponseEvent"].(map[string]interface{}); ok {
2233+ if c, ok := assistantResp["content"].(string); ok {
2234+ contentDelta = c
2235+ }
2236+ // Extract stop_reason from assistantResponseEvent
2237+ if sr := kirocommon.GetString(assistantResp, "stop_reason"); sr != "" {
2238+ upstreamStopReason = sr
2239+ log.Debugf("kiro: streamToChannel found stop_reason in assistantResponseEvent: %s", upstreamStopReason)
2240+ }
2241+ if sr := kirocommon.GetString(assistantResp, "stopReason"); sr != "" {
2242+ upstreamStopReason = sr
2243+ log.Debugf("kiro: streamToChannel found stopReason in assistantResponseEvent: %s", upstreamStopReason)
2244+ }
2245+ // Extract tool uses from response
2246+ if tus, ok := assistantResp["toolUses"].([]interface{}); ok {
2247+ for _, tuRaw := range tus {
2248+ if tu, ok := tuRaw.(map[string]interface{}); ok {
2249+ toolUses = append(toolUses, tu)
2250+ }
2251+ }
2252+ }
2253+ }
2254+ if contentDelta == "" {
2255+ if c, ok := event["content"].(string); ok {
2256+ contentDelta = c
2257+ }
2258+ }
2259+ // Direct tool uses
2260+ if tus, ok := event["toolUses"].([]interface{}); ok {
2261+ for _, tuRaw := range tus {
2262+ if tu, ok := tuRaw.(map[string]interface{}); ok {
2263+ toolUses = append(toolUses, tu)
2264+ }
2265+ }
2266+ }
2267+
2268+ // Handle text content with thinking mode support
2269+ if contentDelta != "" {
2270+ // NOTE: Duplicate content filtering was removed because it incorrectly
2271+ // filtered out legitimate repeated content (like consecutive newlines "\n\n").
2272+ // Streaming naturally can have identical chunks that are valid content.
2273+
2274+ outputLen += len(contentDelta)
2275+ // Accumulate content for streaming token calculation
2276+ accumulatedContent.WriteString(contentDelta)
2277+
2278+ // Real-time usage estimation: Check if we should send a usage update
2279+ // This helps clients track context usage during long thinking sessions
2280+ shouldSendUsageUpdate := false
2281+ if accumulatedContent.Len()-lastUsageUpdateLen >= usageUpdateCharThreshold {
2282+ shouldSendUsageUpdate = true
2283+ } else if time.Since(lastUsageUpdateTime) >= usageUpdateTimeInterval && accumulatedContent.Len() > lastUsageUpdateLen {
2284+ shouldSendUsageUpdate = true
2285+ }
2286+
2287+ if shouldSendUsageUpdate {
2288+ // Calculate current output tokens using tiktoken
2289+ var currentOutputTokens int64
2290+ if enc, encErr := getTokenizer(model); encErr == nil {
2291+ if tokenCount, countErr := enc.Count(accumulatedContent.String()); countErr == nil {
2292+ currentOutputTokens = int64(tokenCount)
2293+ }
2294+ }
2295+ // Fallback to character estimation if tiktoken fails
2296+ if currentOutputTokens == 0 {
2297+ currentOutputTokens = int64(accumulatedContent.Len() / 4)
2298+ if currentOutputTokens == 0 {
2299+ currentOutputTokens = 1
2300+ }
2301+ }
2302+
2303+ // Only send update if token count has changed significantly (at least 10 tokens)
2304+ if currentOutputTokens > lastReportedOutputTokens+10 {
2305+ // Send ping event with usage information
2306+ // This is a non-blocking update that clients can optionally process
2307+ pingEvent := kiroclaude.BuildClaudePingEventWithUsage(totalUsage.InputTokens, currentOutputTokens)
2308+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, pingEvent, &translatorParam)
2309+ for _, chunk := range sseData {
2310+ if chunk != "" {
2311+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2312+ }
2313+ }
2314+
2315+ lastReportedOutputTokens = currentOutputTokens
2316+ log.Debugf("kiro: sent real-time usage update - input: %d, output: %d (accumulated: %d chars)",
2317+ totalUsage.InputTokens, currentOutputTokens, accumulatedContent.Len())
2318+ }
2319+
2320+ lastUsageUpdateLen = accumulatedContent.Len()
2321+ lastUsageUpdateTime = time.Now()
2322+ }
2323+
2324+ // TAG-BASED THINKING PARSING: Parse <thinking> tags from content
2325+ // Combine pending content with new content for processing
2326+ pendingContent.WriteString(contentDelta)
2327+ processContent := pendingContent.String()
2328+ pendingContent.Reset()
2329+
2330+ // Process content looking for thinking tags
2331+ for len(processContent) > 0 {
2332+ if inThinkBlock {
2333+ // We're inside a thinking block, look for </thinking>
2334+ endIdx := strings.Index(processContent, kirocommon.ThinkingEndTag)
2335+ if endIdx >= 0 {
2336+ // Found end tag - emit thinking content before the tag
2337+ thinkingText := processContent[:endIdx]
2338+ if thinkingText != "" {
2339+ // Ensure thinking block is open
2340+ if !isThinkingBlockOpen {
2341+ contentBlockIndex++
2342+ thinkingBlockIndex = contentBlockIndex
2343+ isThinkingBlockOpen = true
2344+ blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
2345+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
2346+ for _, chunk := range sseData {
2347+ if chunk != "" {
2348+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2349+ }
2350+ }
2351+ }
2352+ // Send thinking delta
2353+ thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
2354+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
2355+ for _, chunk := range sseData {
2356+ if chunk != "" {
2357+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2358+ }
2359+ }
2360+ accumulatedThinkingContent.WriteString(thinkingText)
2361+ }
2362+ // Close thinking block
2363+ if isThinkingBlockOpen {
2364+ blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
2365+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2366+ for _, chunk := range sseData {
2367+ if chunk != "" {
2368+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2369+ }
2370+ }
2371+ isThinkingBlockOpen = false
2372+ }
2373+ inThinkBlock = false
2374+ processContent = processContent[endIdx+len(kirocommon.ThinkingEndTag):]
2375+ log.Debugf("kiro: closed thinking block, remaining content: %d chars", len(processContent))
2376+ } else {
2377+ // No end tag found - check for partial match at end
2378+ partialMatch := false
2379+ for i := 1; i < len(kirocommon.ThinkingEndTag) && i <= len(processContent); i++ {
2380+ if strings.HasSuffix(processContent, kirocommon.ThinkingEndTag[:i]) {
2381+ // Possible partial tag at end, buffer it
2382+ pendingContent.WriteString(processContent[len(processContent)-i:])
2383+ processContent = processContent[:len(processContent)-i]
2384+ partialMatch = true
2385+ break
2386+ }
2387+ }
2388+ if !partialMatch || len(processContent) > 0 {
2389+ // Emit all as thinking content
2390+ if processContent != "" {
2391+ if !isThinkingBlockOpen {
2392+ contentBlockIndex++
2393+ thinkingBlockIndex = contentBlockIndex
2394+ isThinkingBlockOpen = true
2395+ blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
2396+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
2397+ for _, chunk := range sseData {
2398+ if chunk != "" {
2399+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2400+ }
2401+ }
2402+ }
2403+ thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(processContent, thinkingBlockIndex)
2404+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
2405+ for _, chunk := range sseData {
2406+ if chunk != "" {
2407+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2408+ }
2409+ }
2410+ accumulatedThinkingContent.WriteString(processContent)
2411+ }
2412+ }
2413+ processContent = ""
2414+ }
2415+ } else {
2416+ // Not in thinking block, look for <thinking>
2417+ startIdx := strings.Index(processContent, kirocommon.ThinkingStartTag)
2418+ if startIdx >= 0 {
2419+ // Found start tag - emit text content before the tag
2420+ textBefore := processContent[:startIdx]
2421+ if textBefore != "" {
2422+ // Close thinking block if open
2423+ if isThinkingBlockOpen {
2424+ blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
2425+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2426+ for _, chunk := range sseData {
2427+ if chunk != "" {
2428+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2429+ }
2430+ }
2431+ isThinkingBlockOpen = false
2432+ }
2433+ // Ensure text block is open
2434+ if !isTextBlockOpen {
2435+ contentBlockIndex++
2436+ isTextBlockOpen = true
2437+ blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
2438+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
2439+ for _, chunk := range sseData {
2440+ if chunk != "" {
2441+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2442+ }
2443+ }
2444+ }
2445+ // Send text delta
2446+ claudeEvent := kiroclaude.BuildClaudeStreamEvent(textBefore, contentBlockIndex)
2447+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
2448+ for _, chunk := range sseData {
2449+ if chunk != "" {
2450+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2451+ }
2452+ }
2453+ }
2454+ // Close text block before entering thinking
2455+ if isTextBlockOpen {
2456+ blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
2457+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2458+ for _, chunk := range sseData {
2459+ if chunk != "" {
2460+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2461+ }
2462+ }
2463+ isTextBlockOpen = false
2464+ }
2465+ inThinkBlock = true
2466+ processContent = processContent[startIdx+len(kirocommon.ThinkingStartTag):]
2467+ log.Debugf("kiro: entered thinking block")
2468+ } else {
2469+ // No start tag found - check for partial match at end
2470+ partialMatch := false
2471+ for i := 1; i < len(kirocommon.ThinkingStartTag) && i <= len(processContent); i++ {
2472+ if strings.HasSuffix(processContent, kirocommon.ThinkingStartTag[:i]) {
2473+ // Possible partial tag at end, buffer it
2474+ pendingContent.WriteString(processContent[len(processContent)-i:])
2475+ processContent = processContent[:len(processContent)-i]
2476+ partialMatch = true
2477+ break
2478+ }
2479+ }
2480+ if !partialMatch || len(processContent) > 0 {
2481+ // Emit all as text content
2482+ if processContent != "" {
2483+ if !isTextBlockOpen {
2484+ contentBlockIndex++
2485+ isTextBlockOpen = true
2486+ blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
2487+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
2488+ for _, chunk := range sseData {
2489+ if chunk != "" {
2490+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2491+ }
2492+ }
2493+ }
2494+ claudeEvent := kiroclaude.BuildClaudeStreamEvent(processContent, contentBlockIndex)
2495+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
2496+ for _, chunk := range sseData {
2497+ if chunk != "" {
2498+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2499+ }
2500+ }
2501+ }
2502+ }
2503+ processContent = ""
2504+ }
2505+ }
2506+ }
2507+ }
2508+
2509+ // Handle tool uses in response (with deduplication)
2510+ for _, tu := range toolUses {
2511+ toolUseID := kirocommon.GetString(tu, "toolUseId")
2512+ toolName := kirocommon.GetString(tu, "name")
2513+
2514+ // Check for duplicate
2515+ if processedIDs[toolUseID] {
2516+ log.Debugf("kiro: skipping duplicate tool use in stream: %s", toolUseID)
2517+ continue
2518+ }
2519+ processedIDs[toolUseID] = true
2520+
2521+ hasToolUses = true
2522+ // Close text block if open before starting tool_use block
2523+ if isTextBlockOpen && contentBlockIndex >= 0 {
2524+ blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
2525+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2526+ for _, chunk := range sseData {
2527+ if chunk != "" {
2528+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2529+ }
2530+ }
2531+ isTextBlockOpen = false
2532+ }
2533+
2534+ // Emit tool_use content block
2535+ contentBlockIndex++
2536+
2537+ blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", toolUseID, toolName)
2538+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
2539+ for _, chunk := range sseData {
2540+ if chunk != "" {
2541+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2542+ }
2543+ }
2544+
2545+ // Send input_json_delta with the tool input
2546+ if input, ok := tu["input"].(map[string]interface{}); ok {
2547+ inputJSON, err := json.Marshal(input)
2548+ if err != nil {
2549+ log.Debugf("kiro: failed to marshal tool input: %v", err)
2550+ // Don't continue - still need to close the block
2551+ } else {
2552+ inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
2553+ sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
2554+ for _, chunk := range sseData {
2555+ if chunk != "" {
2556+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2557+ }
2558+ }
2559+ }
2560+ }
2561+
2562+ // Close tool_use block (always close even if input marshal failed)
2563+ blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
2564+ sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2565+ for _, chunk := range sseData {
2566+ if chunk != "" {
2567+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2568+ }
2569+ }
2570+ }
2571+
2572+ case "reasoningContentEvent":
2573+ // Handle official reasoningContentEvent from Kiro API
2574+ // This replaces tag-based thinking detection with the proper event type
2575+ // Official format: { text: string, signature?: string, redactedContent?: base64 }
2576+ var thinkingText string
2577+ var signature string
2578+
2579+ if re, ok := event["reasoningContentEvent"].(map[string]interface{}); ok {
2580+ if text, ok := re["text"].(string); ok {
2581+ thinkingText = text
2582+ }
2583+ if sig, ok := re["signature"].(string); ok {
2584+ signature = sig
2585+ if len(sig) > 20 {
2586+ log.Debugf("kiro: reasoningContentEvent has signature: %s...", sig[:20])
2587+ } else {
2588+ log.Debugf("kiro: reasoningContentEvent has signature: %s", sig)
2589+ }
2590+ }
2591+ } else {
2592+ // Try direct fields
2593+ if text, ok := event["text"].(string); ok {
2594+ thinkingText = text
2595+ }
2596+ if sig, ok := event["signature"].(string); ok {
2597+ signature = sig
2598+ }
2599+ }
2600+
2601+ if thinkingText != "" {
2602+ // Close text block if open before starting thinking block
2603+ if isTextBlockOpen && contentBlockIndex >= 0 {
2604+ blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
2605+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2606+ for _, chunk := range sseData {
2607+ if chunk != "" {
2608+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2609+ }
2610+ }
2611+ isTextBlockOpen = false
2612+ }
2613+
2614+ // Start thinking block if not already open
2615+ if !isThinkingBlockOpen {
2616+ contentBlockIndex++
2617+ thinkingBlockIndex = contentBlockIndex
2618+ isThinkingBlockOpen = true
2619+ blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
2620+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
2621+ for _, chunk := range sseData {
2622+ if chunk != "" {
2623+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2624+ }
2625+ }
2626+ }
2627+
2628+ // Send thinking content
2629+ thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
2630+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
2631+ for _, chunk := range sseData {
2632+ if chunk != "" {
2633+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2634+ }
2635+ }
2636+
2637+ // Accumulate for token counting
2638+ accumulatedThinkingContent.WriteString(thinkingText)
2639+ log.Debugf("kiro: received reasoningContentEvent, text length: %d, has signature: %v", len(thinkingText), signature != "")
2640+ }
2641+
2642+ // Note: We don't close the thinking block here - it will be closed when we see
2643+ // the next assistantResponseEvent or at the end of the stream
2644+ _ = signature // Signature can be used for verification if needed
2645+
2646+ case "toolUseEvent":
2647+ // Handle dedicated tool use events with input buffering
2648+ completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs)
2649+ currentToolUse = newState
2650+
2651+ // Emit completed tool uses
2652+ for _, tu := range completedToolUses {
2653+ hasToolUses = true
2654+
2655+ // Close text block if open
2656+ if isTextBlockOpen && contentBlockIndex >= 0 {
2657+ blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
2658+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2659+ for _, chunk := range sseData {
2660+ if chunk != "" {
2661+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2662+ }
2663+ }
2664+ isTextBlockOpen = false
2665+ }
2666+
2667+ contentBlockIndex++
2668+
2669+ blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name)
2670+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
2671+ for _, chunk := range sseData {
2672+ if chunk != "" {
2673+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2674+ }
2675+ }
2676+
2677+ if tu.Input != nil {
2678+ inputJSON, err := json.Marshal(tu.Input)
2679+ if err != nil {
2680+ log.Debugf("kiro: failed to marshal tool input in toolUseEvent: %v", err)
2681+ } else {
2682+ inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
2683+ sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
2684+ for _, chunk := range sseData {
2685+ if chunk != "" {
2686+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2687+ }
2688+ }
2689+ }
2690+ }
2691+
2692+ blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
2693+ sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2694+ for _, chunk := range sseData {
2695+ if chunk != "" {
2696+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2697+ }
2698+ }
2699+ }
2700+
2701+ case "supplementaryWebLinksEvent":
2702+ if inputTokens, ok := event["inputTokens"].(float64); ok {
2703+ totalUsage.InputTokens = int64(inputTokens)
2704+ }
2705+ if outputTokens, ok := event["outputTokens"].(float64); ok {
2706+ totalUsage.OutputTokens = int64(outputTokens)
2707+ }
2708+
2709+ case "messageMetadataEvent", "metadataEvent":
2710+ // Handle message metadata events which contain token counts
2711+ // Official format: { tokenUsage: { outputTokens, totalTokens, uncachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens, contextUsagePercentage } }
2712+ var metadata map[string]interface{}
2713+ if m, ok := event["messageMetadataEvent"].(map[string]interface{}); ok {
2714+ metadata = m
2715+ } else if m, ok := event["metadataEvent"].(map[string]interface{}); ok {
2716+ metadata = m
2717+ } else {
2718+ metadata = event // event itself might be the metadata
2719+ }
2720+
2721+ // Check for nested tokenUsage object (official format)
2722+ if tokenUsage, ok := metadata["tokenUsage"].(map[string]interface{}); ok {
2723+ // outputTokens - precise output token count
2724+ if outputTokens, ok := tokenUsage["outputTokens"].(float64); ok {
2725+ totalUsage.OutputTokens = int64(outputTokens)
2726+ hasUpstreamUsage = true
2727+ log.Infof("kiro: streamToChannel found precise outputTokens in tokenUsage: %d", totalUsage.OutputTokens)
2728+ }
2729+ // totalTokens - precise total token count
2730+ if totalTokens, ok := tokenUsage["totalTokens"].(float64); ok {
2731+ totalUsage.TotalTokens = int64(totalTokens)
2732+ log.Infof("kiro: streamToChannel found precise totalTokens in tokenUsage: %d", totalUsage.TotalTokens)
2733+ }
2734+ // uncachedInputTokens - input tokens not from cache
2735+ if uncachedInputTokens, ok := tokenUsage["uncachedInputTokens"].(float64); ok {
2736+ totalUsage.InputTokens = int64(uncachedInputTokens)
2737+ hasUpstreamUsage = true
2738+ log.Infof("kiro: streamToChannel found uncachedInputTokens in tokenUsage: %d", totalUsage.InputTokens)
2739+ }
2740+ // cacheReadInputTokens - tokens read from cache
2741+ if cacheReadTokens, ok := tokenUsage["cacheReadInputTokens"].(float64); ok {
2742+ // Add to input tokens if we have uncached tokens, otherwise use as input
2743+ if totalUsage.InputTokens > 0 {
2744+ totalUsage.InputTokens += int64(cacheReadTokens)
2745+ } else {
2746+ totalUsage.InputTokens = int64(cacheReadTokens)
2747+ }
2748+ hasUpstreamUsage = true
2749+ log.Debugf("kiro: streamToChannel found cacheReadInputTokens in tokenUsage: %d", int64(cacheReadTokens))
2750+ }
2751+ // contextUsagePercentage - can be used as fallback for input token estimation
2752+ if ctxPct, ok := tokenUsage["contextUsagePercentage"].(float64); ok {
2753+ upstreamContextPercentage = ctxPct
2754+ log.Debugf("kiro: streamToChannel found contextUsagePercentage in tokenUsage: %.2f%%", ctxPct)
2755+ }
2756+ }
2757+
2758+ // Fallback: check for direct fields in metadata (legacy format)
2759+ if totalUsage.InputTokens == 0 {
2760+ if inputTokens, ok := metadata["inputTokens"].(float64); ok {
2761+ totalUsage.InputTokens = int64(inputTokens)
2762+ hasUpstreamUsage = true
2763+ log.Debugf("kiro: streamToChannel found inputTokens in messageMetadataEvent: %d", totalUsage.InputTokens)
2764+ }
2765+ }
2766+ if totalUsage.OutputTokens == 0 {
2767+ if outputTokens, ok := metadata["outputTokens"].(float64); ok {
2768+ totalUsage.OutputTokens = int64(outputTokens)
2769+ hasUpstreamUsage = true
2770+ log.Debugf("kiro: streamToChannel found outputTokens in messageMetadataEvent: %d", totalUsage.OutputTokens)
2771+ }
2772+ }
2773+ if totalUsage.TotalTokens == 0 {
2774+ if totalTokens, ok := metadata["totalTokens"].(float64); ok {
2775+ totalUsage.TotalTokens = int64(totalTokens)
2776+ log.Debugf("kiro: streamToChannel found totalTokens in messageMetadataEvent: %d", totalUsage.TotalTokens)
2777+ }
2778+ }
2779+
2780+ case "usageEvent", "usage":
2781+ // Handle dedicated usage events
2782+ if inputTokens, ok := event["inputTokens"].(float64); ok {
2783+ totalUsage.InputTokens = int64(inputTokens)
2784+ log.Debugf("kiro: streamToChannel found inputTokens in usageEvent: %d", totalUsage.InputTokens)
2785+ }
2786+ if outputTokens, ok := event["outputTokens"].(float64); ok {
2787+ totalUsage.OutputTokens = int64(outputTokens)
2788+ log.Debugf("kiro: streamToChannel found outputTokens in usageEvent: %d", totalUsage.OutputTokens)
2789+ }
2790+ if totalTokens, ok := event["totalTokens"].(float64); ok {
2791+ totalUsage.TotalTokens = int64(totalTokens)
2792+ log.Debugf("kiro: streamToChannel found totalTokens in usageEvent: %d", totalUsage.TotalTokens)
2793+ }
2794+ // Also check nested usage object
2795+ if usageObj, ok := event["usage"].(map[string]interface{}); ok {
2796+ if inputTokens, ok := usageObj["input_tokens"].(float64); ok {
2797+ totalUsage.InputTokens = int64(inputTokens)
2798+ } else if inputTokens, ok := usageObj["prompt_tokens"].(float64); ok {
2799+ totalUsage.InputTokens = int64(inputTokens)
2800+ }
2801+ if outputTokens, ok := usageObj["output_tokens"].(float64); ok {
2802+ totalUsage.OutputTokens = int64(outputTokens)
2803+ } else if outputTokens, ok := usageObj["completion_tokens"].(float64); ok {
2804+ totalUsage.OutputTokens = int64(outputTokens)
2805+ }
2806+ if totalTokens, ok := usageObj["total_tokens"].(float64); ok {
2807+ totalUsage.TotalTokens = int64(totalTokens)
2808+ }
2809+ log.Debugf("kiro: streamToChannel found usage object: input=%d, output=%d, total=%d",
2810+ totalUsage.InputTokens, totalUsage.OutputTokens, totalUsage.TotalTokens)
2811+ }
2812+
2813+ case "metricsEvent":
2814+ // Handle metrics events which may contain usage data
2815+ if metrics, ok := event["metricsEvent"].(map[string]interface{}); ok {
2816+ if inputTokens, ok := metrics["inputTokens"].(float64); ok {
2817+ totalUsage.InputTokens = int64(inputTokens)
2818+ }
2819+ if outputTokens, ok := metrics["outputTokens"].(float64); ok {
2820+ totalUsage.OutputTokens = int64(outputTokens)
2821+ }
2822+ log.Debugf("kiro: streamToChannel found metricsEvent: input=%d, output=%d",
2823+ totalUsage.InputTokens, totalUsage.OutputTokens)
2824+ }
2825+ }
2826+
2827+ // Check nested usage event
2828+ if usageEvent, ok := event["supplementaryWebLinksEvent"].(map[string]interface{}); ok {
2829+ if inputTokens, ok := usageEvent["inputTokens"].(float64); ok {
2830+ totalUsage.InputTokens = int64(inputTokens)
2831+ }
2832+ if outputTokens, ok := usageEvent["outputTokens"].(float64); ok {
2833+ totalUsage.OutputTokens = int64(outputTokens)
2834+ }
2835+ }
2836+
2837+ // Check for direct token fields in any event (fallback)
2838+ if totalUsage.InputTokens == 0 {
2839+ if inputTokens, ok := event["inputTokens"].(float64); ok {
2840+ totalUsage.InputTokens = int64(inputTokens)
2841+ log.Debugf("kiro: streamToChannel found direct inputTokens: %d", totalUsage.InputTokens)
2842+ }
2843+ }
2844+ if totalUsage.OutputTokens == 0 {
2845+ if outputTokens, ok := event["outputTokens"].(float64); ok {
2846+ totalUsage.OutputTokens = int64(outputTokens)
2847+ log.Debugf("kiro: streamToChannel found direct outputTokens: %d", totalUsage.OutputTokens)
2848+ }
2849+ }
2850+
2851+ // Check for usage object in any event (OpenAI format)
2852+ if totalUsage.InputTokens == 0 || totalUsage.OutputTokens == 0 {
2853+ if usageObj, ok := event["usage"].(map[string]interface{}); ok {
2854+ if totalUsage.InputTokens == 0 {
2855+ if inputTokens, ok := usageObj["input_tokens"].(float64); ok {
2856+ totalUsage.InputTokens = int64(inputTokens)
2857+ } else if inputTokens, ok := usageObj["prompt_tokens"].(float64); ok {
2858+ totalUsage.InputTokens = int64(inputTokens)
2859+ }
2860+ }
2861+ if totalUsage.OutputTokens == 0 {
2862+ if outputTokens, ok := usageObj["output_tokens"].(float64); ok {
2863+ totalUsage.OutputTokens = int64(outputTokens)
2864+ } else if outputTokens, ok := usageObj["completion_tokens"].(float64); ok {
2865+ totalUsage.OutputTokens = int64(outputTokens)
2866+ }
2867+ }
2868+ if totalUsage.TotalTokens == 0 {
2869+ if totalTokens, ok := usageObj["total_tokens"].(float64); ok {
2870+ totalUsage.TotalTokens = int64(totalTokens)
2871+ }
2872+ }
2873+ log.Debugf("kiro: streamToChannel found usage object (fallback): input=%d, output=%d, total=%d",
2874+ totalUsage.InputTokens, totalUsage.OutputTokens, totalUsage.TotalTokens)
2875+ }
2876+ }
2877+ }
2878+
2879+ // Close content block if open
2880+ if isTextBlockOpen && contentBlockIndex >= 0 {
2881+ blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
2882+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
2883+ for _, chunk := range sseData {
2884+ if chunk != "" {
2885+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2886+ }
2887+ }
2888+ }
2889+
2890+ // Streaming token calculation - calculate output tokens from accumulated content
2891+ // Only use local estimation if server didn't provide usage (server-side usage takes priority)
2892+ if totalUsage.OutputTokens == 0 && accumulatedContent.Len() > 0 {
2893+ // Try to use tiktoken for accurate counting
2894+ if enc, err := getTokenizer(model); err == nil {
2895+ if tokenCount, countErr := enc.Count(accumulatedContent.String()); countErr == nil {
2896+ totalUsage.OutputTokens = int64(tokenCount)
2897+ log.Debugf("kiro: streamToChannel calculated output tokens using tiktoken: %d", totalUsage.OutputTokens)
2898+ } else {
2899+ // Fallback on count error: estimate from character count
2900+ totalUsage.OutputTokens = int64(accumulatedContent.Len() / 4)
2901+ if totalUsage.OutputTokens == 0 {
2902+ totalUsage.OutputTokens = 1
2903+ }
2904+ log.Debugf("kiro: streamToChannel tiktoken count failed, estimated from chars: %d", totalUsage.OutputTokens)
2905+ }
2906+ } else {
2907+ // Fallback: estimate from character count (roughly 4 chars per token)
2908+ totalUsage.OutputTokens = int64(accumulatedContent.Len() / 4)
2909+ if totalUsage.OutputTokens == 0 {
2910+ totalUsage.OutputTokens = 1
2911+ }
2912+ log.Debugf("kiro: streamToChannel estimated output tokens from chars: %d (content len: %d)", totalUsage.OutputTokens, accumulatedContent.Len())
2913+ }
2914+ } else if totalUsage.OutputTokens == 0 && outputLen > 0 {
2915+ // Legacy fallback using outputLen
2916+ totalUsage.OutputTokens = int64(outputLen / 4)
2917+ if totalUsage.OutputTokens == 0 {
2918+ totalUsage.OutputTokens = 1
2919+ }
2920+ }
2921+
2922+ // Use contextUsagePercentage to calculate more accurate input tokens
2923+ // Kiro model has 200k max context, contextUsagePercentage represents the percentage used
2924+ // Formula: input_tokens = contextUsagePercentage * 200000 / 100
2925+ // Note: The effective input context is ~170k (200k - 30k reserved for output)
2926+ if upstreamContextPercentage > 0 {
2927+ // Calculate input tokens from context percentage
2928+ // Using 200k as the base since that's what Kiro reports against
2929+ calculatedInputTokens := int64(upstreamContextPercentage * 200000 / 100)
2930+
2931+ // Only use calculated value if it's significantly different from local estimate
2932+ // This provides more accurate token counts based on upstream data
2933+ if calculatedInputTokens > 0 {
2934+ localEstimate := totalUsage.InputTokens
2935+ totalUsage.InputTokens = calculatedInputTokens
2936+ log.Debugf("kiro: using contextUsagePercentage (%.2f%%) to calculate input tokens: %d (local estimate was: %d)",
2937+ upstreamContextPercentage, calculatedInputTokens, localEstimate)
2938+ }
2939+ }
2940+
2941+ totalUsage.TotalTokens = totalUsage.InputTokens + totalUsage.OutputTokens
2942+
2943+ // Log upstream usage information if received
2944+ if hasUpstreamUsage {
2945+ log.Debugf("kiro: upstream usage - credits: %.4f, context: %.2f%%, final tokens - input: %d, output: %d, total: %d",
2946+ upstreamCreditUsage, upstreamContextPercentage,
2947+ totalUsage.InputTokens, totalUsage.OutputTokens, totalUsage.TotalTokens)
2948+ }
2949+
2950+ // Determine stop reason: prefer upstream, then detect tool_use, default to end_turn
2951+ stopReason := upstreamStopReason
2952+ if stopReason == "" {
2953+ if hasToolUses {
2954+ stopReason = "tool_use"
2955+ log.Debugf("kiro: streamToChannel using fallback stop_reason: tool_use")
2956+ } else {
2957+ stopReason = "end_turn"
2958+ log.Debugf("kiro: streamToChannel using fallback stop_reason: end_turn")
2959+ }
2960+ }
2961+
2962+ // Log warning if response was truncated due to max_tokens
2963+ if stopReason == "max_tokens" {
2964+ log.Warnf("kiro: response truncated due to max_tokens limit (streamToChannel)")
2965+ }
2966+
2967+ // Send message_delta event
2968+ msgDelta := kiroclaude.BuildClaudeMessageDeltaEvent(stopReason, totalUsage)
2969+ sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgDelta, &translatorParam)
2970+ for _, chunk := range sseData {
2971+ if chunk != "" {
2972+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2973+ }
2974+ }
2975+
2976+ // Send message_stop event separately
2977+ msgStop := kiroclaude.BuildClaudeMessageStopOnlyEvent()
2978+ sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStop, &translatorParam)
2979+ for _, chunk := range sseData {
2980+ if chunk != "" {
2981+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
2982+ }
2983+ }
2984+ // reporter.publish is called via defer
2985+}
2986+
2987+// NOTE: Claude SSE event builders moved to internal/translator/kiro/claude/kiro_claude_stream.go
2988+// The executor now uses kiroclaude.BuildClaude*Event() functions instead
2989+
2990+// CountTokens counts tokens locally using tiktoken since Kiro API doesn't expose a token counting endpoint.
2991+// This provides approximate token counts for client requests.
2992+func (e *KiroExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
2993+ // Use tiktoken for local token counting
2994+ enc, err := getTokenizer(req.Model)
2995+ if err != nil {
2996+ log.Warnf("kiro: CountTokens failed to get tokenizer: %v, falling back to estimate", err)
2997+ // Fallback: estimate from payload size (roughly 4 chars per token)
2998+ estimatedTokens := len(req.Payload) / 4
2999+ if estimatedTokens == 0 && len(req.Payload) > 0 {
3000+ estimatedTokens = 1
3001+ }
3002+ return cliproxyexecutor.Response{
3003+ Payload: []byte(fmt.Sprintf(`{"count":%d}`, estimatedTokens)),
3004+ }, nil
3005+ }
3006+
3007+ // Try to count tokens from the request payload
3008+ var totalTokens int64
3009+
3010+ // Try OpenAI chat format first
3011+ if tokens, countErr := countOpenAIChatTokens(enc, req.Payload); countErr == nil && tokens > 0 {
3012+ totalTokens = tokens
3013+ log.Debugf("kiro: CountTokens counted %d tokens using OpenAI chat format", totalTokens)
3014+ } else {
3015+ // Fallback: count raw payload tokens
3016+ if tokenCount, countErr := enc.Count(string(req.Payload)); countErr == nil {
3017+ totalTokens = int64(tokenCount)
3018+ log.Debugf("kiro: CountTokens counted %d tokens from raw payload", totalTokens)
3019+ } else {
3020+ // Final fallback: estimate from payload size
3021+ totalTokens = int64(len(req.Payload) / 4)
3022+ if totalTokens == 0 && len(req.Payload) > 0 {
3023+ totalTokens = 1
3024+ }
3025+ log.Debugf("kiro: CountTokens estimated %d tokens from payload size", totalTokens)
3026+ }
3027+ }
3028+
3029+ return cliproxyexecutor.Response{
3030+ Payload: []byte(fmt.Sprintf(`{"count":%d}`, totalTokens)),
3031+ }, nil
3032+}
3033+
3034+// Refresh refreshes the Kiro OAuth token.
3035+// Supports both AWS Builder ID (SSO OIDC) and Google OAuth (social login).
3036+// Uses mutex to prevent race conditions when multiple concurrent requests try to refresh.
3037+func (e *KiroExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
3038+ // Serialize token refresh operations to prevent race conditions
3039+ e.refreshMu.Lock()
3040+ defer e.refreshMu.Unlock()
3041+
3042+ var authID string
3043+ if auth != nil {
3044+ authID = auth.ID
3045+ } else {
3046+ authID = "<nil>"
3047+ }
3048+ log.Debugf("kiro executor: refresh called for auth %s", authID)
3049+ if auth == nil {
3050+ return nil, fmt.Errorf("kiro executor: auth is nil")
3051+ }
3052+
3053+ // Double-check: After acquiring lock, verify token still needs refresh
3054+ // Another goroutine may have already refreshed while we were waiting
3055+ // NOTE: This check has a design limitation - it reads from the auth object passed in,
3056+ // not from persistent storage. If another goroutine returns a new Auth object (via Clone),
3057+ // this check won't see those updates. The mutex still prevents truly concurrent refreshes,
3058+ // but queued goroutines may still attempt redundant refreshes. This is acceptable as
3059+ // the refresh operation is idempotent and the extra API calls are infrequent.
3060+ if auth.Metadata != nil {
3061+ if lastRefresh, ok := auth.Metadata["last_refresh"].(string); ok {
3062+ if refreshTime, err := time.Parse(time.RFC3339, lastRefresh); err == nil {
3063+ // If token was refreshed within the last 30 seconds, skip refresh
3064+ if time.Since(refreshTime) < 30*time.Second {
3065+ log.Debugf("kiro executor: token was recently refreshed by another goroutine, skipping")
3066+ return auth, nil
3067+ }
3068+ }
3069+ }
3070+ // Also check if expires_at is now in the future with sufficient buffer
3071+ if expiresAt, ok := auth.Metadata["expires_at"].(string); ok {
3072+ if expTime, err := time.Parse(time.RFC3339, expiresAt); err == nil {
3073+ // If token expires more than 5 minutes from now, it's still valid
3074+ if time.Until(expTime) > 5*time.Minute {
3075+ log.Debugf("kiro executor: token is still valid (expires in %v), skipping refresh", time.Until(expTime))
3076+ // CRITICAL FIX: Set NextRefreshAfter to prevent frequent refresh checks
3077+ // Without this, shouldRefresh() will return true again in 5 seconds
3078+ updated := auth.Clone()
3079+ // Set next refresh to 5 minutes before expiry, or at least 30 seconds from now
3080+ nextRefresh := expTime.Add(-5 * time.Minute)
3081+ minNextRefresh := time.Now().Add(30 * time.Second)
3082+ if nextRefresh.Before(minNextRefresh) {
3083+ nextRefresh = minNextRefresh
3084+ }
3085+ updated.NextRefreshAfter = nextRefresh
3086+ log.Debugf("kiro executor: setting NextRefreshAfter to %v (in %v)", nextRefresh.Format(time.RFC3339), time.Until(nextRefresh))
3087+ return updated, nil
3088+ }
3089+ }
3090+ }
3091+ }
3092+
3093+ var refreshToken string
3094+ var clientID, clientSecret string
3095+ var authMethod string
3096+ var region, startURL string
3097+
3098+ if auth.Metadata != nil {
3099+ if rt, ok := auth.Metadata["refresh_token"].(string); ok {
3100+ refreshToken = rt
3101+ }
3102+ if cid, ok := auth.Metadata["client_id"].(string); ok {
3103+ clientID = cid
3104+ }
3105+ if cs, ok := auth.Metadata["client_secret"].(string); ok {
3106+ clientSecret = cs
3107+ }
3108+ if am, ok := auth.Metadata["auth_method"].(string); ok {
3109+ authMethod = am
3110+ }
3111+ if r, ok := auth.Metadata["region"].(string); ok {
3112+ region = r
3113+ }
3114+ if su, ok := auth.Metadata["start_url"].(string); ok {
3115+ startURL = su
3116+ }
3117+ }
3118+
3119+ if refreshToken == "" {
3120+ return nil, fmt.Errorf("kiro executor: refresh token not found")
3121+ }
3122+
3123+ var tokenData *kiroauth.KiroTokenData
3124+ var err error
3125+
3126+ ssoClient := kiroauth.NewSSOOIDCClient(e.cfg)
3127+
3128+ // Use SSO OIDC refresh for AWS Builder ID or IDC, otherwise use Kiro's OAuth refresh endpoint
3129+ switch {
3130+ case clientID != "" && clientSecret != "" && authMethod == "idc" && region != "":
3131+ // IDC refresh with region-specific endpoint
3132+ log.Debugf("kiro executor: using SSO OIDC refresh for IDC (region=%s)", region)
3133+ tokenData, err = ssoClient.RefreshTokenWithRegion(ctx, clientID, clientSecret, refreshToken, region, startURL)
3134+ case clientID != "" && clientSecret != "" && authMethod == "builder-id":
3135+ // Builder ID refresh with default endpoint
3136+ log.Debugf("kiro executor: using SSO OIDC refresh for AWS Builder ID")
3137+ tokenData, err = ssoClient.RefreshToken(ctx, clientID, clientSecret, refreshToken)
3138+ default:
3139+ // Fallback to Kiro's OAuth refresh endpoint (for social auth: Google/GitHub)
3140+ log.Debugf("kiro executor: using Kiro OAuth refresh endpoint")
3141+ oauth := kiroauth.NewKiroOAuth(e.cfg)
3142+ tokenData, err = oauth.RefreshToken(ctx, refreshToken)
3143+ }
3144+
3145+ if err != nil {
3146+ return nil, fmt.Errorf("kiro executor: token refresh failed: %w", err)
3147+ }
3148+
3149+ updated := auth.Clone()
3150+ now := time.Now()
3151+ updated.UpdatedAt = now
3152+ updated.LastRefreshedAt = now
3153+
3154+ if updated.Metadata == nil {
3155+ updated.Metadata = make(map[string]any)
3156+ }
3157+ updated.Metadata["access_token"] = tokenData.AccessToken
3158+ updated.Metadata["refresh_token"] = tokenData.RefreshToken
3159+ updated.Metadata["expires_at"] = tokenData.ExpiresAt
3160+ updated.Metadata["last_refresh"] = now.Format(time.RFC3339)
3161+ if tokenData.ProfileArn != "" {
3162+ updated.Metadata["profile_arn"] = tokenData.ProfileArn
3163+ }
3164+ if tokenData.AuthMethod != "" {
3165+ updated.Metadata["auth_method"] = tokenData.AuthMethod
3166+ }
3167+ if tokenData.Provider != "" {
3168+ updated.Metadata["provider"] = tokenData.Provider
3169+ }
3170+ // Preserve client credentials for future refreshes (AWS Builder ID)
3171+ if tokenData.ClientID != "" {
3172+ updated.Metadata["client_id"] = tokenData.ClientID
3173+ }
3174+ if tokenData.ClientSecret != "" {
3175+ updated.Metadata["client_secret"] = tokenData.ClientSecret
3176+ }
3177+
3178+ if updated.Attributes == nil {
3179+ updated.Attributes = make(map[string]string)
3180+ }
3181+ updated.Attributes["access_token"] = tokenData.AccessToken
3182+ if tokenData.ProfileArn != "" {
3183+ updated.Attributes["profile_arn"] = tokenData.ProfileArn
3184+ }
3185+
3186+ // NextRefreshAfter is aligned with RefreshLead (5min)
3187+ if expiresAt, parseErr := time.Parse(time.RFC3339, tokenData.ExpiresAt); parseErr == nil {
3188+ updated.NextRefreshAfter = expiresAt.Add(-5 * time.Minute)
3189+ }
3190+
3191+ log.Infof("kiro executor: token refreshed successfully, expires at %s", tokenData.ExpiresAt)
3192+ return updated, nil
3193+}
3194+
3195+// persistRefreshedAuth persists a refreshed auth record to disk.
3196+// This ensures token refreshes from inline retry are saved to the auth file.
3197+func (e *KiroExecutor) persistRefreshedAuth(auth *cliproxyauth.Auth) error {
3198+ if auth == nil || auth.Metadata == nil {
3199+ return fmt.Errorf("kiro executor: cannot persist nil auth or metadata")
3200+ }
3201+
3202+ // Determine the file path from auth attributes or filename
3203+ var authPath string
3204+ if auth.Attributes != nil {
3205+ if p := strings.TrimSpace(auth.Attributes["path"]); p != "" {
3206+ authPath = p
3207+ }
3208+ }
3209+ if authPath == "" {
3210+ fileName := strings.TrimSpace(auth.FileName)
3211+ if fileName == "" {
3212+ return fmt.Errorf("kiro executor: auth has no file path or filename")
3213+ }
3214+ if filepath.IsAbs(fileName) {
3215+ authPath = fileName
3216+ } else if e.cfg != nil && e.cfg.AuthDir != "" {
3217+ authPath = filepath.Join(e.cfg.AuthDir, fileName)
3218+ } else {
3219+ return fmt.Errorf("kiro executor: cannot determine auth file path")
3220+ }
3221+ }
3222+
3223+ // Marshal metadata to JSON
3224+ raw, err := json.Marshal(auth.Metadata)
3225+ if err != nil {
3226+ return fmt.Errorf("kiro executor: marshal metadata failed: %w", err)
3227+ }
3228+
3229+ // Write to temp file first, then rename (atomic write)
3230+ tmp := authPath + ".tmp"
3231+ if err := os.WriteFile(tmp, raw, 0o600); err != nil {
3232+ return fmt.Errorf("kiro executor: write temp auth file failed: %w", err)
3233+ }
3234+ if err := os.Rename(tmp, authPath); err != nil {
3235+ return fmt.Errorf("kiro executor: rename auth file failed: %w", err)
3236+ }
3237+
3238+ log.Debugf("kiro executor: persisted refreshed auth to %s", authPath)
3239+ return nil
3240+}
3241+
3242+// isTokenExpired checks if a JWT access token has expired.
3243+// Returns true if the token is expired or cannot be parsed.
3244+func (e *KiroExecutor) isTokenExpired(accessToken string) bool {
3245+ if accessToken == "" {
3246+ return true
3247+ }
3248+
3249+ // JWT tokens have 3 parts separated by dots
3250+ parts := strings.Split(accessToken, ".")
3251+ if len(parts) != 3 {
3252+ // Not a JWT token, assume not expired
3253+ return false
3254+ }
3255+
3256+ // Decode the payload (second part)
3257+ // JWT uses base64url encoding without padding (RawURLEncoding)
3258+ payload := parts[1]
3259+ decoded, err := base64.RawURLEncoding.DecodeString(payload)
3260+ if err != nil {
3261+ // Try with padding added as fallback
3262+ switch len(payload) % 4 {
3263+ case 2:
3264+ payload += "=="
3265+ case 3:
3266+ payload += "="
3267+ }
3268+ decoded, err = base64.URLEncoding.DecodeString(payload)
3269+ if err != nil {
3270+ log.Debugf("kiro: failed to decode JWT payload: %v", err)
3271+ return false
3272+ }
3273+ }
3274+
3275+ var claims struct {
3276+ Exp int64 `json:"exp"`
3277+ }
3278+ if err := json.Unmarshal(decoded, &claims); err != nil {
3279+ log.Debugf("kiro: failed to parse JWT claims: %v", err)
3280+ return false
3281+ }
3282+
3283+ if claims.Exp == 0 {
3284+ // No expiration claim, assume not expired
3285+ return false
3286+ }
3287+
3288+ expTime := time.Unix(claims.Exp, 0)
3289+ now := time.Now()
3290+
3291+ // Consider token expired if it expires within 1 minute (buffer for clock skew)
3292+ isExpired := now.After(expTime) || expTime.Sub(now) < time.Minute
3293+ if isExpired {
3294+ log.Debugf("kiro: token expired at %s (now: %s)", expTime.Format(time.RFC3339), now.Format(time.RFC3339))
3295+ }
3296+
3297+ return isExpired
3298+}
3299+
3300+// NOTE: Message merging functions moved to internal/translator/kiro/common/message_merge.go
3301+// NOTE: Tool calling support functions moved to internal/translator/kiro/claude/kiro_claude_tools.go
3302+// The executor now uses kiroclaude.* and kirocommon.* functions instead
internal/runtime/executor/proxy_helpers.go+44−5
@@ -6,6 +6,7 @@ import ( · 5 unmodified lines
66 "net/http"
77 "net/url"
88 "strings"
9+ "sync"
910 "time"
1011
1112 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
@@ -14,11 +15,19 @@ import ( · 2 unmodified lines
1415 "golang.org/x/net/proxy"
1516 )
1617
18+// httpClientCache caches HTTP clients by proxy URL to enable connection reuse
19+var (
20+ httpClientCache = make(map[string]*http.Client)
21+ httpClientCacheMutex sync.RWMutex
22+)
23+
1724 // newProxyAwareHTTPClient creates an HTTP client with proper proxy configuration priority:
1825 // 1. Use auth.ProxyURL if configured (highest priority)
1926 // 2. Use cfg.ProxyURL if auth proxy is not configured
2027 // 3. Use RoundTripper from context if neither are configured
2128 //
29+// This function caches HTTP clients by proxy URL to enable TCP/TLS connection reuse.
30+//
2231 // Parameters:
2332 // - ctx: The context containing optional RoundTripper
2433 // - cfg: The application configuration
@@ -28,11 +37,6 @@ import ( · 3 unmodified lines
2837 // Returns:
2938 // - *http.Client: An HTTP client with configured proxy or transport
3039 func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
31− httpClient := &http.Client{}
32− if timeout > 0 {
33− httpClient.Timeout = timeout
34− }
35−
3640 // Priority 1: Use auth.ProxyURL if configured
3741 var proxyURL string
3842 if auth != nil {
@@ -44,11 +48,39 @@ func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip · 5 unmodified lines
4448 proxyURL = strings.TrimSpace(cfg.ProxyURL)
4549 }
4650
51+ // Build cache key from proxy URL (empty string for no proxy)
52+ cacheKey := proxyURL
53+
54+ // Check cache first
55+ httpClientCacheMutex.RLock()
56+ if cachedClient, ok := httpClientCache[cacheKey]; ok {
57+ httpClientCacheMutex.RUnlock()
58+ // Return a wrapper with the requested timeout but shared transport
59+ if timeout > 0 {
60+ return &http.Client{
61+ Transport: cachedClient.Transport,
62+ Timeout: timeout,
63+ }
64+ }
65+ return cachedClient
66+ }
67+ httpClientCacheMutex.RUnlock()
68+
69+ // Create new client
70+ httpClient := &http.Client{}
71+ if timeout > 0 {
72+ httpClient.Timeout = timeout
73+ }
74+
4775 // If we have a proxy URL configured, set up the transport
4876 if proxyURL != "" {
4977 transport := buildProxyTransport(proxyURL)
5078 if transport != nil {
5179 httpClient.Transport = transport
80+ // Cache the client
81+ httpClientCacheMutex.Lock()
82+ httpClientCache[cacheKey] = httpClient
83+ httpClientCacheMutex.Unlock()
5284 return httpClient
5385 }
5486 // If proxy setup failed, log and fall through to context RoundTripper
@@ -60,6 +92,13 @@ func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip · 5 unmodified lines
6092 httpClient.Transport = rt
6193 }
6294
95+ // Cache the client for no-proxy case
96+ if proxyURL == "" {
97+ httpClientCacheMutex.Lock()
98+ httpClientCache[cacheKey] = httpClient
99+ httpClientCacheMutex.Unlock()
100+ }
101+
63102 return httpClient
64103 }
65104
internal/runtime/executor/token_helpers.go+276−15
@@ -2,43 +2,109 @@ package executor · 1 unmodified line
22
33 import (
44 "fmt"
5+ "regexp"
6+ "strconv"
57 "strings"
8+ "sync"
69
710 "github.com/tidwall/gjson"
811 "github.com/tiktoken-go/tokenizer"
912 )
1013
14+// tokenizerCache stores tokenizer instances to avoid repeated creation
15+var tokenizerCache sync.Map
16+
17+// TokenizerWrapper wraps a tokenizer codec with an adjustment factor for models
18+// where tiktoken may not accurately estimate token counts (e.g., Claude models)
19+type TokenizerWrapper struct {
20+ Codec tokenizer.Codec
21+ AdjustmentFactor float64 // 1.0 means no adjustment, >1.0 means tiktoken underestimates
22+}
23+
24+// Count returns the token count with adjustment factor applied
25+func (tw *TokenizerWrapper) Count(text string) (int, error) {
26+ count, err := tw.Codec.Count(text)
27+ if err != nil {
28+ return 0, err
29+ }
30+ if tw.AdjustmentFactor != 1.0 && tw.AdjustmentFactor > 0 {
31+ return int(float64(count) * tw.AdjustmentFactor), nil
32+ }
33+ return count, nil
34+}
35+
36+// getTokenizer returns a cached tokenizer for the given model.
37+// This improves performance by avoiding repeated tokenizer creation.
38+func getTokenizer(model string) (*TokenizerWrapper, error) {
39+ // Check cache first
40+ if cached, ok := tokenizerCache.Load(model); ok {
41+ return cached.(*TokenizerWrapper), nil
42+ }
43+
44+ // Cache miss, create new tokenizer
45+ wrapper, err := tokenizerForModel(model)
46+ if err != nil {
47+ return nil, err
48+ }
49+
50+ // Store in cache (use LoadOrStore to handle race conditions)
51+ actual, _ := tokenizerCache.LoadOrStore(model, wrapper)
52+ return actual.(*TokenizerWrapper), nil
53+}
54+
1155 // tokenizerForModel returns a tokenizer codec suitable for an OpenAI-style model id.
12−func tokenizerForModel(model string) (tokenizer.Codec, error) {
56+// For Claude models, applies a 1.1 adjustment factor since tiktoken may underestimate.
57+func tokenizerForModel(model string) (*TokenizerWrapper, error) {
1358 sanitized := strings.ToLower(strings.TrimSpace(model))
59+
60+ // Claude models use cl100k_base with 1.1 adjustment factor
61+ // because tiktoken may underestimate Claude's actual token count
62+ if strings.Contains(sanitized, "claude") || strings.HasPrefix(sanitized, "kiro-") || strings.HasPrefix(sanitized, "amazonq-") {
63+ enc, err := tokenizer.Get(tokenizer.Cl100kBase)
64+ if err != nil {
65+ return nil, err
66+ }
67+ return &TokenizerWrapper{Codec: enc, AdjustmentFactor: 1.1}, nil
68+ }
69+
70+ var enc tokenizer.Codec
71+ var err error
72+
1473 switch {
1574 case sanitized == "":
16− return tokenizer.Get(tokenizer.Cl100kBase)
17− case strings.HasPrefix(sanitized, "gpt-5"):
18− return tokenizer.ForModel(tokenizer.GPT5)
75+ enc, err = tokenizer.Get(tokenizer.Cl100kBase)
76+ case strings.HasPrefix(sanitized, "gpt-5.2"):
77+ enc, err = tokenizer.ForModel(tokenizer.GPT5)
1978 case strings.HasPrefix(sanitized, "gpt-5.1"):
20− return tokenizer.ForModel(tokenizer.GPT5)
79+ enc, err = tokenizer.ForModel(tokenizer.GPT5)
80+ case strings.HasPrefix(sanitized, "gpt-5"):
81+ enc, err = tokenizer.ForModel(tokenizer.GPT5)
2182 case strings.HasPrefix(sanitized, "gpt-4.1"):
22− return tokenizer.ForModel(tokenizer.GPT41)
83+ enc, err = tokenizer.ForModel(tokenizer.GPT41)
2384 case strings.HasPrefix(sanitized, "gpt-4o"):
24− return tokenizer.ForModel(tokenizer.GPT4o)
85+ enc, err = tokenizer.ForModel(tokenizer.GPT4o)
2586 case strings.HasPrefix(sanitized, "gpt-4"):
26− return tokenizer.ForModel(tokenizer.GPT4)
87+ enc, err = tokenizer.ForModel(tokenizer.GPT4)
2788 case strings.HasPrefix(sanitized, "gpt-3.5"), strings.HasPrefix(sanitized, "gpt-3"):
28− return tokenizer.ForModel(tokenizer.GPT35Turbo)
89+ enc, err = tokenizer.ForModel(tokenizer.GPT35Turbo)
2990 case strings.HasPrefix(sanitized, "o1"):
30− return tokenizer.ForModel(tokenizer.O1)
91+ enc, err = tokenizer.ForModel(tokenizer.O1)
3192 case strings.HasPrefix(sanitized, "o3"):
32− return tokenizer.ForModel(tokenizer.O3)
93+ enc, err = tokenizer.ForModel(tokenizer.O3)
3394 case strings.HasPrefix(sanitized, "o4"):
34− return tokenizer.ForModel(tokenizer.O4Mini)
95+ enc, err = tokenizer.ForModel(tokenizer.O4Mini)
3596 default:
36− return tokenizer.Get(tokenizer.O200kBase)
97+ enc, err = tokenizer.Get(tokenizer.O200kBase)
3798 }
99+
100+ if err != nil {
101+ return nil, err
102+ }
103+ return &TokenizerWrapper{Codec: enc, AdjustmentFactor: 1.0}, nil
38104 }
39105
40106 // countOpenAIChatTokens approximates prompt tokens for OpenAI chat completions payloads.
41−func countOpenAIChatTokens(enc tokenizer.Codec, payload []byte) (int64, error) {
107+func countOpenAIChatTokens(enc *TokenizerWrapper, payload []byte) (int64, error) {
42108 if enc == nil {
43109 return 0, fmt.Errorf("encoder is nil")
44110 }
@@ -62,11 +128,206 @@ func countOpenAIChatTokens(enc tokenizer.Codec, payload []byte) (int64, error) { · 17 unmodified lines
62128 return 0, nil
63129 }
64130
131+ // Count text tokens
65132 count, err := enc.Count(joined)
66133 if err != nil {
67134 return 0, err
68135 }
69− return int64(count), nil
136+
137+ // Extract and add image tokens from placeholders
138+ imageTokens := extractImageTokens(joined)
139+
140+ return int64(count) + int64(imageTokens), nil
141+}
142+
143+// countClaudeChatTokens approximates prompt tokens for Claude API chat completions payloads.
144+// This handles Claude's message format with system, messages, and tools.
145+// Image tokens are estimated based on image dimensions when available.
146+func countClaudeChatTokens(enc *TokenizerWrapper, payload []byte) (int64, error) {
147+ if enc == nil {
148+ return 0, fmt.Errorf("encoder is nil")
149+ }
150+ if len(payload) == 0 {
151+ return 0, nil
152+ }
153+
154+ root := gjson.ParseBytes(payload)
155+ segments := make([]string, 0, 32)
156+
157+ // Collect system prompt (can be string or array of content blocks)
158+ collectClaudeSystem(root.Get("system"), &segments)
159+
160+ // Collect messages
161+ collectClaudeMessages(root.Get("messages"), &segments)
162+
163+ // Collect tools
164+ collectClaudeTools(root.Get("tools"), &segments)
165+
166+ joined := strings.TrimSpace(strings.Join(segments, "\n"))
167+ if joined == "" {
168+ return 0, nil
169+ }
170+
171+ // Count text tokens
172+ count, err := enc.Count(joined)
173+ if err != nil {
174+ return 0, err
175+ }
176+
177+ // Extract and add image tokens from placeholders
178+ imageTokens := extractImageTokens(joined)
179+
180+ return int64(count) + int64(imageTokens), nil
181+}
182+
183+// imageTokenPattern matches [IMAGE:xxx tokens] format for extracting estimated image tokens
184+var imageTokenPattern = regexp.MustCompile(`\[IMAGE:(\d+) tokens\]`)
185+
186+// extractImageTokens extracts image token estimates from placeholder text.
187+// Placeholders are in the format [IMAGE:xxx tokens] where xxx is the estimated token count.
188+func extractImageTokens(text string) int {
189+ matches := imageTokenPattern.FindAllStringSubmatch(text, -1)
190+ total := 0
191+ for _, match := range matches {
192+ if len(match) > 1 {
193+ if tokens, err := strconv.Atoi(match[1]); err == nil {
194+ total += tokens
195+ }
196+ }
197+ }
198+ return total
199+}
200+
201+// estimateImageTokens calculates estimated tokens for an image based on dimensions.
202+// Based on Claude's image token calculation: tokens ≈ (width * height) / 750
203+// Minimum 85 tokens, maximum 1590 tokens (for 1568x1568 images).
204+func estimateImageTokens(width, height float64) int {
205+ if width <= 0 || height <= 0 {
206+ // No valid dimensions, use default estimate (medium-sized image)
207+ return 1000
208+ }
209+
210+ tokens := int(width * height / 750)
211+
212+ // Apply bounds
213+ if tokens < 85 {
214+ tokens = 85
215+ }
216+ if tokens > 1590 {
217+ tokens = 1590
218+ }
219+
220+ return tokens
221+}
222+
223+// collectClaudeSystem extracts text from Claude's system field.
224+// System can be a string or an array of content blocks.
225+func collectClaudeSystem(system gjson.Result, segments *[]string) {
226+ if !system.Exists() {
227+ return
228+ }
229+ if system.Type == gjson.String {
230+ addIfNotEmpty(segments, system.String())
231+ return
232+ }
233+ if system.IsArray() {
234+ system.ForEach(func(_, block gjson.Result) bool {
235+ blockType := block.Get("type").String()
236+ if blockType == "text" || blockType == "" {
237+ addIfNotEmpty(segments, block.Get("text").String())
238+ }
239+ // Also handle plain string blocks
240+ if block.Type == gjson.String {
241+ addIfNotEmpty(segments, block.String())
242+ }
243+ return true
244+ })
245+ }
246+}
247+
248+// collectClaudeMessages extracts text from Claude's messages array.
249+func collectClaudeMessages(messages gjson.Result, segments *[]string) {
250+ if !messages.Exists() || !messages.IsArray() {
251+ return
252+ }
253+ messages.ForEach(func(_, message gjson.Result) bool {
254+ addIfNotEmpty(segments, message.Get("role").String())
255+ collectClaudeContent(message.Get("content"), segments)
256+ return true
257+ })
258+}
259+
260+// collectClaudeContent extracts text from Claude's content field.
261+// Content can be a string or an array of content blocks.
262+// For images, estimates token count based on dimensions when available.
263+func collectClaudeContent(content gjson.Result, segments *[]string) {
264+ if !content.Exists() {
265+ return
266+ }
267+ if content.Type == gjson.String {
268+ addIfNotEmpty(segments, content.String())
269+ return
270+ }
271+ if content.IsArray() {
272+ content.ForEach(func(_, part gjson.Result) bool {
273+ partType := part.Get("type").String()
274+ switch partType {
275+ case "text":
276+ addIfNotEmpty(segments, part.Get("text").String())
277+ case "image":
278+ // Estimate image tokens based on dimensions if available
279+ source := part.Get("source")
280+ if source.Exists() {
281+ width := source.Get("width").Float()
282+ height := source.Get("height").Float()
283+ if width > 0 && height > 0 {
284+ tokens := estimateImageTokens(width, height)
285+ addIfNotEmpty(segments, fmt.Sprintf("[IMAGE:%d tokens]", tokens))
286+ } else {
287+ // No dimensions available, use default estimate
288+ addIfNotEmpty(segments, "[IMAGE:1000 tokens]")
289+ }
290+ } else {
291+ // No source info, use default estimate
292+ addIfNotEmpty(segments, "[IMAGE:1000 tokens]")
293+ }
294+ case "tool_use":
295+ addIfNotEmpty(segments, part.Get("id").String())
296+ addIfNotEmpty(segments, part.Get("name").String())
297+ if input := part.Get("input"); input.Exists() {
298+ addIfNotEmpty(segments, input.Raw)
299+ }
300+ case "tool_result":
301+ addIfNotEmpty(segments, part.Get("tool_use_id").String())
302+ collectClaudeContent(part.Get("content"), segments)
303+ case "thinking":
304+ addIfNotEmpty(segments, part.Get("thinking").String())
305+ default:
306+ // For unknown types, try to extract any text content
307+ if part.Type == gjson.String {
308+ addIfNotEmpty(segments, part.String())
309+ } else if part.Type == gjson.JSON {
310+ addIfNotEmpty(segments, part.Raw)
311+ }
312+ }
313+ return true
314+ })
315+ }
316+}
317+
318+// collectClaudeTools extracts text from Claude's tools array.
319+func collectClaudeTools(tools gjson.Result, segments *[]string) {
320+ if !tools.Exists() || !tools.IsArray() {
321+ return
322+ }
323+ tools.ForEach(func(_, tool gjson.Result) bool {
324+ addIfNotEmpty(segments, tool.Get("name").String())
325+ addIfNotEmpty(segments, tool.Get("description").String())
326+ if inputSchema := tool.Get("input_schema"); inputSchema.Exists() {
327+ addIfNotEmpty(segments, inputSchema.Raw)
328+ }
329+ return true
330+ })
70331 }
71332
72333 // buildOpenAIUsageJSON returns a minimal usage structure understood by downstream translators.
internal/translator/claude/openai/chat-completions/claude_openai_response.go+4−0
@@ -50,6 +50,10 @@ type ToolCallAccumulator struct { · 49 unmodified lines
5050 // Returns:
5151 // - []string: A slice of strings, each containing an OpenAI-compatible JSON response
5252 func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
53+ var localParam any
54+ if param == nil {
55+ param = &localParam
56+ }
5357 if *param == nil {
5458 *param = &ConvertAnthropicResponseToOpenAIParams{
5559 CreatedAt: 0,
internal/translator/init.go+3−0
@@ -33,4 +33,7 @@ import ( · 32 unmodified lines
3333 _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini"
3434 _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/chat-completions"
3535 _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/responses"
36+
37+ _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/claude"
38+ _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/openai"
3639 )
internal/translator/kiro/claude/init.go+20−0
@@ -0,0 +1,20 @@
1+// Package claude provides translation between Kiro and Claude formats.
2+package claude
3+
4+import (
5+ . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
6+ "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
7+ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
8+)
9+
10+func init() {
11+ translator.Register(
12+ Claude,
13+ Kiro,
14+ ConvertClaudeRequestToKiro,
15+ interfaces.TranslateResponse{
16+ Stream: ConvertKiroStreamToClaude,
17+ NonStream: ConvertKiroNonStreamToClaude,
18+ },
19+ )
20+}
internal/translator/kiro/claude/kiro_claude.go+21−0
@@ -0,0 +1,21 @@
1+// Package claude provides translation between Kiro and Claude formats.
2+// Since Kiro executor generates Claude-compatible SSE format internally (with event: prefix),
3+// translations are pass-through for streaming, but responses need proper formatting.
4+package claude
5+
6+import (
7+ "context"
8+)
9+
10+// ConvertKiroStreamToClaude converts Kiro streaming response to Claude format.
11+// Kiro executor already generates complete SSE format with "event:" prefix,
12+// so this is a simple pass-through.
13+func ConvertKiroStreamToClaude(ctx context.Context, model string, originalRequest, request, rawResponse []byte, param *any) []string {
14+ return []string{string(rawResponse)}
15+}
16+
17+// ConvertKiroNonStreamToClaude converts Kiro non-streaming response to Claude format.
18+// The response is already in Claude format, so this is a pass-through.
19+func ConvertKiroNonStreamToClaude(ctx context.Context, model string, originalRequest, request, rawResponse []byte, param *any) string {
20+ return string(rawResponse)
21+}
internal/translator/kiro/claude/kiro_claude_request.go+809−0
@@ -0,0 +1,809 @@
1+// Package claude provides request translation functionality for Claude API to Kiro format.
2+// It handles parsing and transforming Claude API requests into the Kiro/Amazon Q API format,
3+// extracting model information, system instructions, message contents, and tool declarations.
4+package claude
5+
6+import (
7+ "encoding/json"
8+ "fmt"
9+ "net/http"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/google/uuid"
15+ kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
16+ log "github.com/sirupsen/logrus"
17+ "github.com/tidwall/gjson"
18+)
19+
20+
21+// Kiro API request structs - field order determines JSON key order
22+
23+// KiroPayload is the top-level request structure for Kiro API
24+type KiroPayload struct {
25+ ConversationState KiroConversationState `json:"conversationState"`
26+ ProfileArn string `json:"profileArn,omitempty"`
27+ InferenceConfig *KiroInferenceConfig `json:"inferenceConfig,omitempty"`
28+}
29+
30+// KiroInferenceConfig contains inference parameters for the Kiro API.
31+type KiroInferenceConfig struct {
32+ MaxTokens int `json:"maxTokens,omitempty"`
33+ Temperature float64 `json:"temperature,omitempty"`
34+ TopP float64 `json:"topP,omitempty"`
35+}
36+
37+
38+// KiroConversationState holds the conversation context
39+type KiroConversationState struct {
40+ ChatTriggerType string `json:"chatTriggerType"` // Required: "MANUAL" - must be first field
41+ ConversationID string `json:"conversationId"`
42+ CurrentMessage KiroCurrentMessage `json:"currentMessage"`
43+ History []KiroHistoryMessage `json:"history,omitempty"`
44+}
45+
46+// KiroCurrentMessage wraps the current user message
47+type KiroCurrentMessage struct {
48+ UserInputMessage KiroUserInputMessage `json:"userInputMessage"`
49+}
50+
51+// KiroHistoryMessage represents a message in the conversation history
52+type KiroHistoryMessage struct {
53+ UserInputMessage *KiroUserInputMessage `json:"userInputMessage,omitempty"`
54+ AssistantResponseMessage *KiroAssistantResponseMessage `json:"assistantResponseMessage,omitempty"`
55+}
56+
57+// KiroImage represents an image in Kiro API format
58+type KiroImage struct {
59+ Format string `json:"format"`
60+ Source KiroImageSource `json:"source"`
61+}
62+
63+// KiroImageSource contains the image data
64+type KiroImageSource struct {
65+ Bytes string `json:"bytes"` // base64 encoded image data
66+}
67+
68+// KiroUserInputMessage represents a user message
69+type KiroUserInputMessage struct {
70+ Content string `json:"content"`
71+ ModelID string `json:"modelId"`
72+ Origin string `json:"origin"`
73+ Images []KiroImage `json:"images,omitempty"`
74+ UserInputMessageContext *KiroUserInputMessageContext `json:"userInputMessageContext,omitempty"`
75+}
76+
77+// KiroUserInputMessageContext contains tool-related context
78+type KiroUserInputMessageContext struct {
79+ ToolResults []KiroToolResult `json:"toolResults,omitempty"`
80+ Tools []KiroToolWrapper `json:"tools,omitempty"`
81+}
82+
83+// KiroToolResult represents a tool execution result
84+type KiroToolResult struct {
85+ Content []KiroTextContent `json:"content"`
86+ Status string `json:"status"`
87+ ToolUseID string `json:"toolUseId"`
88+}
89+
90+// KiroTextContent represents text content
91+type KiroTextContent struct {
92+ Text string `json:"text"`
93+}
94+
95+// KiroToolWrapper wraps a tool specification
96+type KiroToolWrapper struct {
97+ ToolSpecification KiroToolSpecification `json:"toolSpecification"`
98+}
99+
100+// KiroToolSpecification defines a tool's schema
101+type KiroToolSpecification struct {
102+ Name string `json:"name"`
103+ Description string `json:"description"`
104+ InputSchema KiroInputSchema `json:"inputSchema"`
105+}
106+
107+// KiroInputSchema wraps the JSON schema for tool input
108+type KiroInputSchema struct {
109+ JSON interface{} `json:"json"`
110+}
111+
112+// KiroAssistantResponseMessage represents an assistant message
113+type KiroAssistantResponseMessage struct {
114+ Content string `json:"content"`
115+ ToolUses []KiroToolUse `json:"toolUses,omitempty"`
116+}
117+
118+// KiroToolUse represents a tool invocation by the assistant
119+type KiroToolUse struct {
120+ ToolUseID string `json:"toolUseId"`
121+ Name string `json:"name"`
122+ Input map[string]interface{} `json:"input"`
123+}
124+
125+// ConvertClaudeRequestToKiro converts a Claude API request to Kiro format.
126+// This is the main entry point for request translation.
127+func ConvertClaudeRequestToKiro(modelName string, inputRawJSON []byte, stream bool) []byte {
128+ // For Kiro, we pass through the Claude format since buildKiroPayload
129+ // expects Claude format and does the conversion internally.
130+ // The actual conversion happens in the executor when building the HTTP request.
131+ return inputRawJSON
132+}
133+
134+// BuildKiroPayload constructs the Kiro API request payload from Claude format.
135+// Supports tool calling - tools are passed via userInputMessageContext.
136+// origin parameter determines which quota to use: "CLI" for Amazon Q, "AI_EDITOR" for Kiro IDE.
137+// isAgentic parameter enables chunked write optimization prompt for -agentic model variants.
138+// isChatOnly parameter disables tool calling for -chat model variants (pure conversation mode).
139+// headers parameter allows checking Anthropic-Beta header for thinking mode detection.
140+// metadata parameter is kept for API compatibility but no longer used for thinking configuration.
141+// Supports thinking mode - when enabled, injects thinking tags into system prompt.
142+// Returns the payload and a boolean indicating whether thinking mode was injected.
143+func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isAgentic, isChatOnly bool, headers http.Header, metadata map[string]any) ([]byte, bool) {
144+ // Extract max_tokens for potential use in inferenceConfig
145+ // Handle -1 as "use maximum" (Kiro max output is ~32000 tokens)
146+ const kiroMaxOutputTokens = 32000
147+ var maxTokens int64
148+ if mt := gjson.GetBytes(claudeBody, "max_tokens"); mt.Exists() {
149+ maxTokens = mt.Int()
150+ if maxTokens == -1 {
151+ maxTokens = kiroMaxOutputTokens
152+ log.Debugf("kiro: max_tokens=-1 converted to %d", kiroMaxOutputTokens)
153+ }
154+ }
155+
156+ // Extract temperature if specified
157+ var temperature float64
158+ var hasTemperature bool
159+ if temp := gjson.GetBytes(claudeBody, "temperature"); temp.Exists() {
160+ temperature = temp.Float()
161+ hasTemperature = true
162+ }
163+
164+ // Extract top_p if specified
165+ var topP float64
166+ var hasTopP bool
167+ if tp := gjson.GetBytes(claudeBody, "top_p"); tp.Exists() {
168+ topP = tp.Float()
169+ hasTopP = true
170+ log.Debugf("kiro: extracted top_p: %.2f", topP)
171+ }
172+
173+ // Normalize origin value for Kiro API compatibility
174+ origin = normalizeOrigin(origin)
175+ log.Debugf("kiro: normalized origin value: %s", origin)
176+
177+ messages := gjson.GetBytes(claudeBody, "messages")
178+
179+ // For chat-only mode, don't include tools
180+ var tools gjson.Result
181+ if !isChatOnly {
182+ tools = gjson.GetBytes(claudeBody, "tools")
183+ }
184+
185+ // Extract system prompt
186+ systemPrompt := extractSystemPrompt(claudeBody)
187+
188+ // Check for thinking mode using the comprehensive IsThinkingEnabledWithHeaders function
189+ // This supports Claude API format, OpenAI reasoning_effort, AMP/Cursor format, and Anthropic-Beta header
190+ thinkingEnabled := IsThinkingEnabledWithHeaders(claudeBody, headers)
191+
192+ // Inject timestamp context
193+ timestamp := time.Now().Format("2006-01-02 15:04:05 MST")
194+ timestampContext := fmt.Sprintf("[Context: Current time is %s]", timestamp)
195+ if systemPrompt != "" {
196+ systemPrompt = timestampContext + "\n\n" + systemPrompt
197+ } else {
198+ systemPrompt = timestampContext
199+ }
200+ log.Debugf("kiro: injected timestamp context: %s", timestamp)
201+
202+ // Inject agentic optimization prompt for -agentic model variants
203+ if isAgentic {
204+ if systemPrompt != "" {
205+ systemPrompt += "\n"
206+ }
207+ systemPrompt += kirocommon.KiroAgenticSystemPrompt
208+ }
209+
210+ // Handle tool_choice parameter - Kiro doesn't support it natively, so we inject system prompt hints
211+ // Claude tool_choice values: {"type": "auto/any/tool", "name": "..."}
212+ toolChoiceHint := extractClaudeToolChoiceHint(claudeBody)
213+ if toolChoiceHint != "" {
214+ if systemPrompt != "" {
215+ systemPrompt += "\n"
216+ }
217+ systemPrompt += toolChoiceHint
218+ log.Debugf("kiro: injected tool_choice hint into system prompt")
219+ }
220+
221+ // Convert Claude tools to Kiro format
222+ kiroTools := convertClaudeToolsToKiro(tools)
223+
224+ // Thinking mode implementation:
225+ // Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
226+ // When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
227+ // rather than inline <thinking> tags in assistantResponseEvent.
228+ // We use a high max_thinking_length to allow extensive reasoning.
229+ if thinkingEnabled {
230+ thinkingHint := `<thinking_mode>enabled</thinking_mode>
231+<max_thinking_length>200000</max_thinking_length>`
232+ if systemPrompt != "" {
233+ systemPrompt = thinkingHint + "\n\n" + systemPrompt
234+ } else {
235+ systemPrompt = thinkingHint
236+ }
237+ log.Infof("kiro: injected thinking prompt (official mode), has_tools: %v", len(kiroTools) > 0)
238+ }
239+
240+ // Process messages and build history
241+ history, currentUserMsg, currentToolResults := processMessages(messages, modelID, origin)
242+
243+ // Build content with system prompt
244+ if currentUserMsg != nil {
245+ currentUserMsg.Content = buildFinalContent(currentUserMsg.Content, systemPrompt, currentToolResults)
246+
247+ // Deduplicate currentToolResults
248+ currentToolResults = deduplicateToolResults(currentToolResults)
249+
250+ // Build userInputMessageContext with tools and tool results
251+ if len(kiroTools) > 0 || len(currentToolResults) > 0 {
252+ currentUserMsg.UserInputMessageContext = &KiroUserInputMessageContext{
253+ Tools: kiroTools,
254+ ToolResults: currentToolResults,
255+ }
256+ }
257+ }
258+
259+ // Build payload
260+ var currentMessage KiroCurrentMessage
261+ if currentUserMsg != nil {
262+ currentMessage = KiroCurrentMessage{UserInputMessage: *currentUserMsg}
263+ } else {
264+ fallbackContent := ""
265+ if systemPrompt != "" {
266+ fallbackContent = "--- SYSTEM PROMPT ---\n" + systemPrompt + "\n--- END SYSTEM PROMPT ---\n"
267+ }
268+ currentMessage = KiroCurrentMessage{UserInputMessage: KiroUserInputMessage{
269+ Content: fallbackContent,
270+ ModelID: modelID,
271+ Origin: origin,
272+ }}
273+ }
274+
275+ // Build inferenceConfig if we have any inference parameters
276+ // Note: Kiro API doesn't actually use max_tokens for thinking budget
277+ var inferenceConfig *KiroInferenceConfig
278+ if maxTokens > 0 || hasTemperature || hasTopP {
279+ inferenceConfig = &KiroInferenceConfig{}
280+ if maxTokens > 0 {
281+ inferenceConfig.MaxTokens = int(maxTokens)
282+ }
283+ if hasTemperature {
284+ inferenceConfig.Temperature = temperature
285+ }
286+ if hasTopP {
287+ inferenceConfig.TopP = topP
288+ }
289+ }
290+
291+ payload := KiroPayload{
292+ ConversationState: KiroConversationState{
293+ ChatTriggerType: "MANUAL",
294+ ConversationID: uuid.New().String(),
295+ CurrentMessage: currentMessage,
296+ History: history,
297+ },
298+ ProfileArn: profileArn,
299+ InferenceConfig: inferenceConfig,
300+ }
301+
302+ result, err := json.Marshal(payload)
303+ if err != nil {
304+ log.Debugf("kiro: failed to marshal payload: %v", err)
305+ return nil, false
306+ }
307+
308+ return result, thinkingEnabled
309+}
310+
311+// normalizeOrigin normalizes origin value for Kiro API compatibility
312+func normalizeOrigin(origin string) string {
313+ switch origin {
314+ case "KIRO_CLI":
315+ return "CLI"
316+ case "KIRO_AI_EDITOR":
317+ return "AI_EDITOR"
318+ case "AMAZON_Q":
319+ return "CLI"
320+ case "KIRO_IDE":
321+ return "AI_EDITOR"
322+ default:
323+ return origin
324+ }
325+}
326+
327+// extractSystemPrompt extracts system prompt from Claude request
328+func extractSystemPrompt(claudeBody []byte) string {
329+ systemField := gjson.GetBytes(claudeBody, "system")
330+ if systemField.IsArray() {
331+ var sb strings.Builder
332+ for _, block := range systemField.Array() {
333+ if block.Get("type").String() == "text" {
334+ sb.WriteString(block.Get("text").String())
335+ } else if block.Type == gjson.String {
336+ sb.WriteString(block.String())
337+ }
338+ }
339+ return sb.String()
340+ }
341+ return systemField.String()
342+}
343+
344+// checkThinkingMode checks if thinking mode is enabled in the Claude request
345+func checkThinkingMode(claudeBody []byte) (bool, int64) {
346+ thinkingEnabled := false
347+ var budgetTokens int64 = 24000
348+
349+ thinkingField := gjson.GetBytes(claudeBody, "thinking")
350+ if thinkingField.Exists() {
351+ thinkingType := thinkingField.Get("type").String()
352+ if thinkingType == "enabled" {
353+ thinkingEnabled = true
354+ if bt := thinkingField.Get("budget_tokens"); bt.Exists() {
355+ budgetTokens = bt.Int()
356+ if budgetTokens <= 0 {
357+ thinkingEnabled = false
358+ log.Debugf("kiro: thinking mode disabled via budget_tokens <= 0")
359+ }
360+ }
361+ if thinkingEnabled {
362+ log.Debugf("kiro: thinking mode enabled via Claude API parameter, budget_tokens: %d", budgetTokens)
363+ }
364+ }
365+ }
366+
367+ return thinkingEnabled, budgetTokens
368+}
369+
370+// hasThinkingTagInBody checks if the request body already contains thinking configuration tags.
371+// This is used to prevent duplicate injection when client (e.g., AMP/Cursor) already includes thinking config.
372+func hasThinkingTagInBody(body []byte) bool {
373+ bodyStr := string(body)
374+ return strings.Contains(bodyStr, "<thinking_mode>") || strings.Contains(bodyStr, "<max_thinking_length>")
375+}
376+
377+
378+// IsThinkingEnabledFromHeader checks if thinking mode is enabled via Anthropic-Beta header.
379+// Claude CLI uses "Anthropic-Beta: interleaved-thinking-2025-05-14" to enable thinking.
380+func IsThinkingEnabledFromHeader(headers http.Header) bool {
381+ if headers == nil {
382+ return false
383+ }
384+ betaHeader := headers.Get("Anthropic-Beta")
385+ if betaHeader == "" {
386+ return false
387+ }
388+ // Check for interleaved-thinking beta feature
389+ if strings.Contains(betaHeader, "interleaved-thinking") {
390+ log.Debugf("kiro: thinking mode enabled via Anthropic-Beta header: %s", betaHeader)
391+ return true
392+ }
393+ return false
394+}
395+
396+// IsThinkingEnabled is a public wrapper to check if thinking mode is enabled.
397+// This is used by the executor to determine whether to parse <thinking> tags in responses.
398+// When thinking is NOT enabled in the request, <thinking> tags in responses should be
399+// treated as regular text content, not as thinking blocks.
400+//
401+// Supports multiple formats:
402+// - Claude API format: thinking.type = "enabled"
403+// - OpenAI format: reasoning_effort parameter
404+// - AMP/Cursor format: <thinking_mode>interleaved</thinking_mode> in system prompt
405+func IsThinkingEnabled(body []byte) bool {
406+ return IsThinkingEnabledWithHeaders(body, nil)
407+}
408+
409+// IsThinkingEnabledWithHeaders checks if thinking mode is enabled from body or headers.
410+// This is the comprehensive check that supports all thinking detection methods:
411+// - Claude API format: thinking.type = "enabled"
412+// - OpenAI format: reasoning_effort parameter
413+// - AMP/Cursor format: <thinking_mode>interleaved</thinking_mode> in system prompt
414+// - Anthropic-Beta header: interleaved-thinking-2025-05-14
415+func IsThinkingEnabledWithHeaders(body []byte, headers http.Header) bool {
416+ // Check Anthropic-Beta header first (Claude Code uses this)
417+ if IsThinkingEnabledFromHeader(headers) {
418+ return true
419+ }
420+
421+ // Check Claude API format first (thinking.type = "enabled")
422+ enabled, _ := checkThinkingMode(body)
423+ if enabled {
424+ log.Debugf("kiro: IsThinkingEnabled returning true (Claude API format)")
425+ return true
426+ }
427+
428+ // Check OpenAI format: reasoning_effort parameter
429+ // Valid values: "low", "medium", "high", "auto" (not "none")
430+ reasoningEffort := gjson.GetBytes(body, "reasoning_effort")
431+ if reasoningEffort.Exists() {
432+ effort := reasoningEffort.String()
433+ if effort != "" && effort != "none" {
434+ log.Debugf("kiro: thinking mode enabled via OpenAI reasoning_effort: %s", effort)
435+ return true
436+ }
437+ }
438+
439+ // Check AMP/Cursor format: <thinking_mode>interleaved</thinking_mode> in system prompt
440+ // This is how AMP client passes thinking configuration
441+ bodyStr := string(body)
442+ if strings.Contains(bodyStr, "<thinking_mode>") && strings.Contains(bodyStr, "</thinking_mode>") {
443+ // Extract thinking mode value
444+ startTag := "<thinking_mode>"
445+ endTag := "</thinking_mode>"
446+ startIdx := strings.Index(bodyStr, startTag)
447+ if startIdx >= 0 {
448+ startIdx += len(startTag)
449+ endIdx := strings.Index(bodyStr[startIdx:], endTag)
450+ if endIdx >= 0 {
451+ thinkingMode := bodyStr[startIdx : startIdx+endIdx]
452+ if thinkingMode == "interleaved" || thinkingMode == "enabled" {
453+ log.Debugf("kiro: thinking mode enabled via AMP/Cursor format: %s", thinkingMode)
454+ return true
455+ }
456+ }
457+ }
458+ }
459+
460+ // Check OpenAI format: max_completion_tokens with reasoning (o1-style)
461+ // Some clients use this to indicate reasoning mode
462+ if gjson.GetBytes(body, "max_completion_tokens").Exists() {
463+ // If max_completion_tokens is set, check if model name suggests reasoning
464+ model := gjson.GetBytes(body, "model").String()
465+ if strings.Contains(strings.ToLower(model), "thinking") ||
466+ strings.Contains(strings.ToLower(model), "reason") {
467+ log.Debugf("kiro: thinking mode enabled via model name hint: %s", model)
468+ return true
469+ }
470+ }
471+
472+ log.Debugf("kiro: IsThinkingEnabled returning false (no thinking mode detected)")
473+ return false
474+}
475+
476+// shortenToolNameIfNeeded shortens tool names that exceed 64 characters.
477+// MCP tools often have long names like "mcp__server-name__tool-name".
478+// This preserves the "mcp__" prefix and last segment when possible.
479+func shortenToolNameIfNeeded(name string) string {
480+ const limit = 64
481+ if len(name) <= limit {
482+ return name
483+ }
484+ // For MCP tools, try to preserve prefix and last segment
485+ if strings.HasPrefix(name, "mcp__") {
486+ idx := strings.LastIndex(name, "__")
487+ if idx > 0 {
488+ cand := "mcp__" + name[idx+2:]
489+ if len(cand) > limit {
490+ return cand[:limit]
491+ }
492+ return cand
493+ }
494+ }
495+ return name[:limit]
496+}
497+
498+// convertClaudeToolsToKiro converts Claude tools to Kiro format
499+func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
500+ var kiroTools []KiroToolWrapper
501+ if !tools.IsArray() {
502+ return kiroTools
503+ }
504+
505+ for _, tool := range tools.Array() {
506+ name := tool.Get("name").String()
507+ description := tool.Get("description").String()
508+ inputSchema := tool.Get("input_schema").Value()
509+
510+ // Shorten tool name if it exceeds 64 characters (common with MCP tools)
511+ originalName := name
512+ name = shortenToolNameIfNeeded(name)
513+ if name != originalName {
514+ log.Debugf("kiro: shortened tool name from '%s' to '%s'", originalName, name)
515+ }
516+
517+ // CRITICAL FIX: Kiro API requires non-empty description
518+ if strings.TrimSpace(description) == "" {
519+ description = fmt.Sprintf("Tool: %s", name)
520+ log.Debugf("kiro: tool '%s' has empty description, using default: %s", name, description)
521+ }
522+
523+ // Truncate long descriptions
524+ if len(description) > kirocommon.KiroMaxToolDescLen {
525+ truncLen := kirocommon.KiroMaxToolDescLen - 30
526+ for truncLen > 0 && !utf8.RuneStart(description[truncLen]) {
527+ truncLen--
528+ }
529+ description = description[:truncLen] + "... (description truncated)"
530+ }
531+
532+ kiroTools = append(kiroTools, KiroToolWrapper{
533+ ToolSpecification: KiroToolSpecification{
534+ Name: name,
535+ Description: description,
536+ InputSchema: KiroInputSchema{JSON: inputSchema},
537+ },
538+ })
539+ }
540+
541+ return kiroTools
542+}
543+
544+// processMessages processes Claude messages and builds Kiro history
545+func processMessages(messages gjson.Result, modelID, origin string) ([]KiroHistoryMessage, *KiroUserInputMessage, []KiroToolResult) {
546+ var history []KiroHistoryMessage
547+ var currentUserMsg *KiroUserInputMessage
548+ var currentToolResults []KiroToolResult
549+
550+ // Merge adjacent messages with the same role
551+ messagesArray := kirocommon.MergeAdjacentMessages(messages.Array())
552+ for i, msg := range messagesArray {
553+ role := msg.Get("role").String()
554+ isLastMessage := i == len(messagesArray)-1
555+
556+ if role == "user" {
557+ userMsg, toolResults := BuildUserMessageStruct(msg, modelID, origin)
558+ if isLastMessage {
559+ currentUserMsg = &userMsg
560+ currentToolResults = toolResults
561+ } else {
562+ // CRITICAL: Kiro API requires content to be non-empty for history messages too
563+ if strings.TrimSpace(userMsg.Content) == "" {
564+ if len(toolResults) > 0 {
565+ userMsg.Content = "Tool results provided."
566+ } else {
567+ userMsg.Content = "Continue"
568+ }
569+ }
570+ // For history messages, embed tool results in context
571+ if len(toolResults) > 0 {
572+ userMsg.UserInputMessageContext = &KiroUserInputMessageContext{
573+ ToolResults: toolResults,
574+ }
575+ }
576+ history = append(history, KiroHistoryMessage{
577+ UserInputMessage: &userMsg,
578+ })
579+ }
580+ } else if role == "assistant" {
581+ assistantMsg := BuildAssistantMessageStruct(msg)
582+ if isLastMessage {
583+ history = append(history, KiroHistoryMessage{
584+ AssistantResponseMessage: &assistantMsg,
585+ })
586+ // Create a "Continue" user message as currentMessage
587+ currentUserMsg = &KiroUserInputMessage{
588+ Content: "Continue",
589+ ModelID: modelID,
590+ Origin: origin,
591+ }
592+ } else {
593+ history = append(history, KiroHistoryMessage{
594+ AssistantResponseMessage: &assistantMsg,
595+ })
596+ }
597+ }
598+ }
599+
600+ return history, currentUserMsg, currentToolResults
601+}
602+
603+// buildFinalContent builds the final content with system prompt
604+func buildFinalContent(content, systemPrompt string, toolResults []KiroToolResult) string {
605+ var contentBuilder strings.Builder
606+
607+ if systemPrompt != "" {
608+ contentBuilder.WriteString("--- SYSTEM PROMPT ---\n")
609+ contentBuilder.WriteString(systemPrompt)
610+ contentBuilder.WriteString("\n--- END SYSTEM PROMPT ---\n\n")
611+ }
612+
613+ contentBuilder.WriteString(content)
614+ finalContent := contentBuilder.String()
615+
616+ // CRITICAL: Kiro API requires content to be non-empty
617+ if strings.TrimSpace(finalContent) == "" {
618+ if len(toolResults) > 0 {
619+ finalContent = "Tool results provided."
620+ } else {
621+ finalContent = "Continue"
622+ }
623+ log.Debugf("kiro: content was empty, using default: %s", finalContent)
624+ }
625+
626+ return finalContent
627+}
628+
629+// deduplicateToolResults removes duplicate tool results
630+func deduplicateToolResults(toolResults []KiroToolResult) []KiroToolResult {
631+ if len(toolResults) == 0 {
632+ return toolResults
633+ }
634+
635+ seenIDs := make(map[string]bool)
636+ unique := make([]KiroToolResult, 0, len(toolResults))
637+ for _, tr := range toolResults {
638+ if !seenIDs[tr.ToolUseID] {
639+ seenIDs[tr.ToolUseID] = true
640+ unique = append(unique, tr)
641+ } else {
642+ log.Debugf("kiro: skipping duplicate toolResult in currentMessage: %s", tr.ToolUseID)
643+ }
644+ }
645+ return unique
646+}
647+
648+// extractClaudeToolChoiceHint extracts tool_choice from Claude request and returns a system prompt hint.
649+// Claude tool_choice values:
650+// - {"type": "auto"}: Model decides (default, no hint needed)
651+// - {"type": "any"}: Must use at least one tool
652+// - {"type": "tool", "name": "..."}: Must use specific tool
653+func extractClaudeToolChoiceHint(claudeBody []byte) string {
654+ toolChoice := gjson.GetBytes(claudeBody, "tool_choice")
655+ if !toolChoice.Exists() {
656+ return ""
657+ }
658+
659+ toolChoiceType := toolChoice.Get("type").String()
660+ switch toolChoiceType {
661+ case "any":
662+ return "[INSTRUCTION: You MUST use at least one of the available tools to respond. Do not respond with text only - always make a tool call.]"
663+ case "tool":
664+ toolName := toolChoice.Get("name").String()
665+ if toolName != "" {
666+ return fmt.Sprintf("[INSTRUCTION: You MUST use the tool named '%s' to respond. Do not use any other tool or respond with text only.]", toolName)
667+ }
668+ case "auto":
669+ // Default behavior, no hint needed
670+ return ""
671+ }
672+
673+ return ""
674+}
675+
676+// BuildUserMessageStruct builds a user message and extracts tool results
677+func BuildUserMessageStruct(msg gjson.Result, modelID, origin string) (KiroUserInputMessage, []KiroToolResult) {
678+ content := msg.Get("content")
679+ var contentBuilder strings.Builder
680+ var toolResults []KiroToolResult
681+ var images []KiroImage
682+
683+ // Track seen toolUseIds to deduplicate
684+ seenToolUseIDs := make(map[string]bool)
685+
686+ if content.IsArray() {
687+ for _, part := range content.Array() {
688+ partType := part.Get("type").String()
689+ switch partType {
690+ case "text":
691+ contentBuilder.WriteString(part.Get("text").String())
692+ case "image":
693+ mediaType := part.Get("source.media_type").String()
694+ data := part.Get("source.data").String()
695+
696+ format := ""
697+ if idx := strings.LastIndex(mediaType, "/"); idx != -1 {
698+ format = mediaType[idx+1:]
699+ }
700+
701+ if format != "" && data != "" {
702+ images = append(images, KiroImage{
703+ Format: format,
704+ Source: KiroImageSource{
705+ Bytes: data,
706+ },
707+ })
708+ }
709+ case "tool_result":
710+ toolUseID := part.Get("tool_use_id").String()
711+
712+ // Skip duplicate toolUseIds
713+ if seenToolUseIDs[toolUseID] {
714+ log.Debugf("kiro: skipping duplicate tool_result with toolUseId: %s", toolUseID)
715+ continue
716+ }
717+ seenToolUseIDs[toolUseID] = true
718+
719+ isError := part.Get("is_error").Bool()
720+ resultContent := part.Get("content")
721+
722+ var textContents []KiroTextContent
723+ if resultContent.IsArray() {
724+ for _, item := range resultContent.Array() {
725+ if item.Get("type").String() == "text" {
726+ textContents = append(textContents, KiroTextContent{Text: item.Get("text").String()})
727+ } else if item.Type == gjson.String {
728+ textContents = append(textContents, KiroTextContent{Text: item.String()})
729+ }
730+ }
731+ } else if resultContent.Type == gjson.String {
732+ textContents = append(textContents, KiroTextContent{Text: resultContent.String()})
733+ }
734+
735+ if len(textContents) == 0 {
736+ textContents = append(textContents, KiroTextContent{Text: "Tool use was cancelled by the user"})
737+ }
738+
739+ status := "success"
740+ if isError {
741+ status = "error"
742+ }
743+
744+ toolResults = append(toolResults, KiroToolResult{
745+ ToolUseID: toolUseID,
746+ Content: textContents,
747+ Status: status,
748+ })
749+ }
750+ }
751+ } else {
752+ contentBuilder.WriteString(content.String())
753+ }
754+
755+ userMsg := KiroUserInputMessage{
756+ Content: contentBuilder.String(),
757+ ModelID: modelID,
758+ Origin: origin,
759+ }
760+
761+ if len(images) > 0 {
762+ userMsg.Images = images
763+ }
764+
765+ return userMsg, toolResults
766+}
767+
768+// BuildAssistantMessageStruct builds an assistant message with tool uses
769+func BuildAssistantMessageStruct(msg gjson.Result) KiroAssistantResponseMessage {
770+ content := msg.Get("content")
771+ var contentBuilder strings.Builder
772+ var toolUses []KiroToolUse
773+
774+ if content.IsArray() {
775+ for _, part := range content.Array() {
776+ partType := part.Get("type").String()
777+ switch partType {
778+ case "text":
779+ contentBuilder.WriteString(part.Get("text").String())
780+ case "tool_use":
781+ toolUseID := part.Get("id").String()
782+ toolName := part.Get("name").String()
783+ toolInput := part.Get("input")
784+
785+ var inputMap map[string]interface{}
786+ if toolInput.IsObject() {
787+ inputMap = make(map[string]interface{})
788+ toolInput.ForEach(func(key, value gjson.Result) bool {
789+ inputMap[key.String()] = value.Value()
790+ return true
791+ })
792+ }
793+
794+ toolUses = append(toolUses, KiroToolUse{
795+ ToolUseID: toolUseID,
796+ Name: toolName,
797+ Input: inputMap,
798+ })
799+ }
800+ }
801+ } else {
802+ contentBuilder.WriteString(content.String())
803+ }
804+
805+ return KiroAssistantResponseMessage{
806+ Content: contentBuilder.String(),
807+ ToolUses: toolUses,
808+ }
809+}
internal/translator/kiro/claude/kiro_claude_response.go+204−0
@@ -0,0 +1,204 @@
1+// Package claude provides response translation functionality for Kiro API to Claude format.
2+// This package handles the conversion of Kiro API responses into Claude-compatible format,
3+// including support for thinking blocks and tool use.
4+package claude
5+
6+import (
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "encoding/json"
10+ "strings"
11+
12+ "github.com/google/uuid"
13+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
14+ log "github.com/sirupsen/logrus"
15+
16+ kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
17+)
18+
19+// generateThinkingSignature generates a signature for thinking content.
20+// This is required by Claude API for thinking blocks in non-streaming responses.
21+// The signature is a base64-encoded hash of the thinking content.
22+func generateThinkingSignature(thinkingContent string) string {
23+ if thinkingContent == "" {
24+ return ""
25+ }
26+ // Generate a deterministic signature based on content hash
27+ hash := sha256.Sum256([]byte(thinkingContent))
28+ return base64.StdEncoding.EncodeToString(hash[:])
29+}
30+
31+// Local references to kirocommon constants for thinking block parsing
32+var (
33+ thinkingStartTag = kirocommon.ThinkingStartTag
34+ thinkingEndTag = kirocommon.ThinkingEndTag
35+)
36+
37+// BuildClaudeResponse constructs a Claude-compatible response.
38+// Supports tool_use blocks when tools are present in the response.
39+// Supports thinking blocks - parses <thinking> tags and converts to Claude thinking content blocks.
40+// stopReason is passed from upstream; fallback logic applied if empty.
41+func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, usageInfo usage.Detail, stopReason string) []byte {
42+ var contentBlocks []map[string]interface{}
43+
44+ // Extract thinking blocks and text from content
45+ if content != "" {
46+ blocks := ExtractThinkingFromContent(content)
47+ contentBlocks = append(contentBlocks, blocks...)
48+
49+ // Log if thinking blocks were extracted
50+ for _, block := range blocks {
51+ if block["type"] == "thinking" {
52+ thinkingContent := block["thinking"].(string)
53+ log.Infof("kiro: buildClaudeResponse extracted thinking block (len: %d)", len(thinkingContent))
54+ }
55+ }
56+ }
57+
58+ // Add tool_use blocks
59+ for _, toolUse := range toolUses {
60+ contentBlocks = append(contentBlocks, map[string]interface{}{
61+ "type": "tool_use",
62+ "id": toolUse.ToolUseID,
63+ "name": toolUse.Name,
64+ "input": toolUse.Input,
65+ })
66+ }
67+
68+ // Ensure at least one content block (Claude API requires non-empty content)
69+ if len(contentBlocks) == 0 {
70+ contentBlocks = append(contentBlocks, map[string]interface{}{
71+ "type": "text",
72+ "text": "",
73+ })
74+ }
75+
76+ // Use upstream stopReason; apply fallback logic if not provided
77+ if stopReason == "" {
78+ stopReason = "end_turn"
79+ if len(toolUses) > 0 {
80+ stopReason = "tool_use"
81+ }
82+ log.Debugf("kiro: buildClaudeResponse using fallback stop_reason: %s", stopReason)
83+ }
84+
85+ // Log warning if response was truncated due to max_tokens
86+ if stopReason == "max_tokens" {
87+ log.Warnf("kiro: response truncated due to max_tokens limit (buildClaudeResponse)")
88+ }
89+
90+ response := map[string]interface{}{
91+ "id": "msg_" + uuid.New().String()[:24],
92+ "type": "message",
93+ "role": "assistant",
94+ "model": model,
95+ "content": contentBlocks,
96+ "stop_reason": stopReason,
97+ "usage": map[string]interface{}{
98+ "input_tokens": usageInfo.InputTokens,
99+ "output_tokens": usageInfo.OutputTokens,
100+ },
101+ }
102+ result, _ := json.Marshal(response)
103+ return result
104+}
105+
106+// ExtractThinkingFromContent parses content to extract thinking blocks and text.
107+// Returns a list of content blocks in the order they appear in the content.
108+// Handles interleaved thinking and text blocks correctly.
109+func ExtractThinkingFromContent(content string) []map[string]interface{} {
110+ var blocks []map[string]interface{}
111+
112+ if content == "" {
113+ return blocks
114+ }
115+
116+ // Check if content contains thinking tags at all
117+ if !strings.Contains(content, thinkingStartTag) {
118+ // No thinking tags, return as plain text
119+ return []map[string]interface{}{
120+ {
121+ "type": "text",
122+ "text": content,
123+ },
124+ }
125+ }
126+
127+ log.Debugf("kiro: extractThinkingFromContent - found thinking tags in content (len: %d)", len(content))
128+
129+ remaining := content
130+
131+ for len(remaining) > 0 {
132+ // Look for <thinking> tag
133+ startIdx := strings.Index(remaining, thinkingStartTag)
134+
135+ if startIdx == -1 {
136+ // No more thinking tags, add remaining as text
137+ if strings.TrimSpace(remaining) != "" {
138+ blocks = append(blocks, map[string]interface{}{
139+ "type": "text",
140+ "text": remaining,
141+ })
142+ }
143+ break
144+ }
145+
146+ // Add text before thinking tag (if any meaningful content)
147+ if startIdx > 0 {
148+ textBefore := remaining[:startIdx]
149+ if strings.TrimSpace(textBefore) != "" {
150+ blocks = append(blocks, map[string]interface{}{
151+ "type": "text",
152+ "text": textBefore,
153+ })
154+ }
155+ }
156+
157+ // Move past the opening tag
158+ remaining = remaining[startIdx+len(thinkingStartTag):]
159+
160+ // Find closing tag
161+ endIdx := strings.Index(remaining, thinkingEndTag)
162+
163+ if endIdx == -1 {
164+ // No closing tag found, treat rest as thinking content (incomplete response)
165+ if strings.TrimSpace(remaining) != "" {
166+ // Generate signature for thinking content (required by Claude API)
167+ signature := generateThinkingSignature(remaining)
168+ blocks = append(blocks, map[string]interface{}{
169+ "type": "thinking",
170+ "thinking": remaining,
171+ "signature": signature,
172+ })
173+ log.Warnf("kiro: extractThinkingFromContent - missing closing </thinking> tag")
174+ }
175+ break
176+ }
177+
178+ // Extract thinking content between tags
179+ thinkContent := remaining[:endIdx]
180+ if strings.TrimSpace(thinkContent) != "" {
181+ // Generate signature for thinking content (required by Claude API)
182+ signature := generateThinkingSignature(thinkContent)
183+ blocks = append(blocks, map[string]interface{}{
184+ "type": "thinking",
185+ "thinking": thinkContent,
186+ "signature": signature,
187+ })
188+ log.Debugf("kiro: extractThinkingFromContent - extracted thinking block (len: %d)", len(thinkContent))
189+ }
190+
191+ // Move past the closing tag
192+ remaining = remaining[endIdx+len(thinkingEndTag):]
193+ }
194+
195+ // If no blocks were created (all whitespace), return empty text block
196+ if len(blocks) == 0 {
197+ blocks = append(blocks, map[string]interface{}{
198+ "type": "text",
199+ "text": "",
200+ })
201+ }
202+
203+ return blocks
204+}
internal/translator/kiro/claude/kiro_claude_stream.go+186−0
@@ -0,0 +1,186 @@
1+// Package claude provides streaming SSE event building for Claude format.
2+// This package handles the construction of Claude-compatible Server-Sent Events (SSE)
3+// for streaming responses from Kiro API.
4+package claude
5+
6+import (
7+ "encoding/json"
8+
9+ "github.com/google/uuid"
10+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
11+)
12+
13+// BuildClaudeMessageStartEvent creates the message_start SSE event
14+func BuildClaudeMessageStartEvent(model string, inputTokens int64) []byte {
15+ event := map[string]interface{}{
16+ "type": "message_start",
17+ "message": map[string]interface{}{
18+ "id": "msg_" + uuid.New().String()[:24],
19+ "type": "message",
20+ "role": "assistant",
21+ "content": []interface{}{},
22+ "model": model,
23+ "stop_reason": nil,
24+ "stop_sequence": nil,
25+ "usage": map[string]interface{}{"input_tokens": inputTokens, "output_tokens": 0},
26+ },
27+ }
28+ result, _ := json.Marshal(event)
29+ return []byte("event: message_start\ndata: " + string(result))
30+}
31+
32+// BuildClaudeContentBlockStartEvent creates a content_block_start SSE event
33+func BuildClaudeContentBlockStartEvent(index int, blockType, toolUseID, toolName string) []byte {
34+ var contentBlock map[string]interface{}
35+ switch blockType {
36+ case "tool_use":
37+ contentBlock = map[string]interface{}{
38+ "type": "tool_use",
39+ "id": toolUseID,
40+ "name": toolName,
41+ "input": map[string]interface{}{},
42+ }
43+ case "thinking":
44+ contentBlock = map[string]interface{}{
45+ "type": "thinking",
46+ "thinking": "",
47+ }
48+ default:
49+ contentBlock = map[string]interface{}{
50+ "type": "text",
51+ "text": "",
52+ }
53+ }
54+
55+ event := map[string]interface{}{
56+ "type": "content_block_start",
57+ "index": index,
58+ "content_block": contentBlock,
59+ }
60+ result, _ := json.Marshal(event)
61+ return []byte("event: content_block_start\ndata: " + string(result))
62+}
63+
64+// BuildClaudeStreamEvent creates a text_delta content_block_delta SSE event
65+func BuildClaudeStreamEvent(contentDelta string, index int) []byte {
66+ event := map[string]interface{}{
67+ "type": "content_block_delta",
68+ "index": index,
69+ "delta": map[string]interface{}{
70+ "type": "text_delta",
71+ "text": contentDelta,
72+ },
73+ }
74+ result, _ := json.Marshal(event)
75+ return []byte("event: content_block_delta\ndata: " + string(result))
76+}
77+
78+// BuildClaudeInputJsonDeltaEvent creates an input_json_delta event for tool use streaming
79+func BuildClaudeInputJsonDeltaEvent(partialJSON string, index int) []byte {
80+ event := map[string]interface{}{
81+ "type": "content_block_delta",
82+ "index": index,
83+ "delta": map[string]interface{}{
84+ "type": "input_json_delta",
85+ "partial_json": partialJSON,
86+ },
87+ }
88+ result, _ := json.Marshal(event)
89+ return []byte("event: content_block_delta\ndata: " + string(result))
90+}
91+
92+// BuildClaudeContentBlockStopEvent creates a content_block_stop SSE event
93+func BuildClaudeContentBlockStopEvent(index int) []byte {
94+ event := map[string]interface{}{
95+ "type": "content_block_stop",
96+ "index": index,
97+ }
98+ result, _ := json.Marshal(event)
99+ return []byte("event: content_block_stop\ndata: " + string(result))
100+}
101+
102+// BuildClaudeThinkingBlockStopEvent creates a content_block_stop SSE event for thinking blocks.
103+func BuildClaudeThinkingBlockStopEvent(index int) []byte {
104+ event := map[string]interface{}{
105+ "type": "content_block_stop",
106+ "index": index,
107+ }
108+ result, _ := json.Marshal(event)
109+ return []byte("event: content_block_stop\ndata: " + string(result))
110+}
111+
112+// BuildClaudeMessageDeltaEvent creates the message_delta event with stop_reason and usage
113+func BuildClaudeMessageDeltaEvent(stopReason string, usageInfo usage.Detail) []byte {
114+ deltaEvent := map[string]interface{}{
115+ "type": "message_delta",
116+ "delta": map[string]interface{}{
117+ "stop_reason": stopReason,
118+ "stop_sequence": nil,
119+ },
120+ "usage": map[string]interface{}{
121+ "input_tokens": usageInfo.InputTokens,
122+ "output_tokens": usageInfo.OutputTokens,
123+ },
124+ }
125+ deltaResult, _ := json.Marshal(deltaEvent)
126+ return []byte("event: message_delta\ndata: " + string(deltaResult))
127+}
128+
129+// BuildClaudeMessageStopOnlyEvent creates only the message_stop event
130+func BuildClaudeMessageStopOnlyEvent() []byte {
131+ stopEvent := map[string]interface{}{
132+ "type": "message_stop",
133+ }
134+ stopResult, _ := json.Marshal(stopEvent)
135+ return []byte("event: message_stop\ndata: " + string(stopResult))
136+}
137+
138+// BuildClaudePingEventWithUsage creates a ping event with embedded usage information.
139+// This is used for real-time usage estimation during streaming.
140+func BuildClaudePingEventWithUsage(inputTokens, outputTokens int64) []byte {
141+ event := map[string]interface{}{
142+ "type": "ping",
143+ "usage": map[string]interface{}{
144+ "input_tokens": inputTokens,
145+ "output_tokens": outputTokens,
146+ "total_tokens": inputTokens + outputTokens,
147+ "estimated": true,
148+ },
149+ }
150+ result, _ := json.Marshal(event)
151+ return []byte("event: ping\ndata: " + string(result))
152+}
153+
154+// BuildClaudeThinkingDeltaEvent creates a thinking_delta event for Claude API compatibility.
155+// This is used when streaming thinking content wrapped in <thinking> tags.
156+func BuildClaudeThinkingDeltaEvent(thinkingDelta string, index int) []byte {
157+ event := map[string]interface{}{
158+ "type": "content_block_delta",
159+ "index": index,
160+ "delta": map[string]interface{}{
161+ "type": "thinking_delta",
162+ "thinking": thinkingDelta,
163+ },
164+ }
165+ result, _ := json.Marshal(event)
166+ return []byte("event: content_block_delta\ndata: " + string(result))
167+}
168+
169+// PendingTagSuffix detects if the buffer ends with a partial prefix of the given tag.
170+// Returns the length of the partial match (0 if no match).
171+// Based on amq2api implementation for handling cross-chunk tag boundaries.
172+func PendingTagSuffix(buffer, tag string) int {
173+ if buffer == "" || tag == "" {
174+ return 0
175+ }
176+ maxLen := len(buffer)
177+ if maxLen > len(tag)-1 {
178+ maxLen = len(tag) - 1
179+ }
180+ for length := maxLen; length > 0; length-- {
181+ if len(buffer) >= length && buffer[len(buffer)-length:] == tag[:length] {
182+ return length
183+ }
184+ }
185+ return 0
186+}
internal/translator/kiro/claude/kiro_claude_tools.go+522−0
@@ -0,0 +1,522 @@
1+// Package claude provides tool calling support for Kiro to Claude translation.
2+// This package handles parsing embedded tool calls, JSON repair, and deduplication.
3+package claude
4+
5+import (
6+ "encoding/json"
7+ "regexp"
8+ "strings"
9+
10+ "github.com/google/uuid"
11+ kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
12+ log "github.com/sirupsen/logrus"
13+)
14+
15+// ToolUseState tracks the state of an in-progress tool use during streaming.
16+type ToolUseState struct {
17+ ToolUseID string
18+ Name string
19+ InputBuffer strings.Builder
20+ IsComplete bool
21+}
22+
23+// Pre-compiled regex patterns for performance
24+var (
25+ // embeddedToolCallPattern matches [Called tool_name with args: {...}] format
26+ embeddedToolCallPattern = regexp.MustCompile(`\[Called\s+([A-Za-z0-9_.-]+)\s+with\s+args:\s*`)
27+ // trailingCommaPattern matches trailing commas before closing braces/brackets
28+ trailingCommaPattern = regexp.MustCompile(`,\s*([}\]])`)
29+)
30+
31+// ParseEmbeddedToolCalls extracts [Called tool_name with args: {...}] format from text.
32+// Kiro sometimes embeds tool calls in text content instead of using toolUseEvent.
33+// Returns the cleaned text (with tool calls removed) and extracted tool uses.
34+func ParseEmbeddedToolCalls(text string, processedIDs map[string]bool) (string, []KiroToolUse) {
35+ if !strings.Contains(text, "[Called") {
36+ return text, nil
37+ }
38+
39+ var toolUses []KiroToolUse
40+ cleanText := text
41+
42+ // Find all [Called markers
43+ matches := embeddedToolCallPattern.FindAllStringSubmatchIndex(text, -1)
44+ if len(matches) == 0 {
45+ return text, nil
46+ }
47+
48+ // Process matches in reverse order to maintain correct indices
49+ for i := len(matches) - 1; i >= 0; i-- {
50+ matchStart := matches[i][0]
51+ toolNameStart := matches[i][2]
52+ toolNameEnd := matches[i][3]
53+
54+ if toolNameStart < 0 || toolNameEnd < 0 {
55+ continue
56+ }
57+
58+ toolName := text[toolNameStart:toolNameEnd]
59+
60+ // Find the JSON object start (after "with args:")
61+ jsonStart := matches[i][1]
62+ if jsonStart >= len(text) {
63+ continue
64+ }
65+
66+ // Skip whitespace to find the opening brace
67+ for jsonStart < len(text) && (text[jsonStart] == ' ' || text[jsonStart] == '\t') {
68+ jsonStart++
69+ }
70+
71+ if jsonStart >= len(text) || text[jsonStart] != '{' {
72+ continue
73+ }
74+
75+ // Find matching closing bracket
76+ jsonEnd := findMatchingBracket(text, jsonStart)
77+ if jsonEnd < 0 {
78+ continue
79+ }
80+
81+ // Extract JSON and find the closing bracket of [Called ...]
82+ jsonStr := text[jsonStart : jsonEnd+1]
83+
84+ // Find the closing ] after the JSON
85+ closingBracket := jsonEnd + 1
86+ for closingBracket < len(text) && text[closingBracket] != ']' {
87+ closingBracket++
88+ }
89+ if closingBracket >= len(text) {
90+ continue
91+ }
92+
93+ // End index of the full tool call (closing ']' inclusive)
94+ matchEnd := closingBracket + 1
95+
96+ // Repair and parse JSON
97+ repairedJSON := RepairJSON(jsonStr)
98+ var inputMap map[string]interface{}
99+ if err := json.Unmarshal([]byte(repairedJSON), &inputMap); err != nil {
100+ log.Debugf("kiro: failed to parse embedded tool call JSON: %v, raw: %s", err, jsonStr)
101+ continue
102+ }
103+
104+ // Generate unique tool ID
105+ toolUseID := "toolu_" + uuid.New().String()[:12]
106+
107+ // Check for duplicates using name+input as key
108+ dedupeKey := toolName + ":" + repairedJSON
109+ if processedIDs != nil {
110+ if processedIDs[dedupeKey] {
111+ log.Debugf("kiro: skipping duplicate embedded tool call: %s", toolName)
112+ // Still remove from text even if duplicate
113+ if matchStart >= 0 && matchEnd <= len(cleanText) && matchStart <= matchEnd {
114+ cleanText = cleanText[:matchStart] + cleanText[matchEnd:]
115+ }
116+ continue
117+ }
118+ processedIDs[dedupeKey] = true
119+ }
120+
121+ toolUses = append(toolUses, KiroToolUse{
122+ ToolUseID: toolUseID,
123+ Name: toolName,
124+ Input: inputMap,
125+ })
126+
127+ log.Infof("kiro: extracted embedded tool call: %s (ID: %s)", toolName, toolUseID)
128+
129+ // Remove from clean text (index-based removal to avoid deleting the wrong occurrence)
130+ if matchStart >= 0 && matchEnd <= len(cleanText) && matchStart <= matchEnd {
131+ cleanText = cleanText[:matchStart] + cleanText[matchEnd:]
132+ }
133+ }
134+
135+ return cleanText, toolUses
136+}
137+
138+// findMatchingBracket finds the index of the closing brace/bracket that matches
139+// the opening one at startPos. Handles nested objects and strings correctly.
140+func findMatchingBracket(text string, startPos int) int {
141+ if startPos >= len(text) {
142+ return -1
143+ }
144+
145+ openChar := text[startPos]
146+ var closeChar byte
147+ switch openChar {
148+ case '{':
149+ closeChar = '}'
150+ case '[':
151+ closeChar = ']'
152+ default:
153+ return -1
154+ }
155+
156+ depth := 1
157+ inString := false
158+ escapeNext := false
159+
160+ for i := startPos + 1; i < len(text); i++ {
161+ char := text[i]
162+
163+ if escapeNext {
164+ escapeNext = false
165+ continue
166+ }
167+
168+ if char == '\\' && inString {
169+ escapeNext = true
170+ continue
171+ }
172+
173+ if char == '"' {
174+ inString = !inString
175+ continue
176+ }
177+
178+ if !inString {
179+ if char == openChar {
180+ depth++
181+ } else if char == closeChar {
182+ depth--
183+ if depth == 0 {
184+ return i
185+ }
186+ }
187+ }
188+ }
189+
190+ return -1
191+}
192+
193+// RepairJSON attempts to fix common JSON issues that may occur in tool call arguments.
194+// Conservative repair strategy:
195+// 1. First try to parse JSON directly - if valid, return as-is
196+// 2. Only attempt repair if parsing fails
197+// 3. After repair, validate the result - if still invalid, return original
198+func RepairJSON(jsonString string) string {
199+ // Handle empty or invalid input
200+ if jsonString == "" {
201+ return "{}"
202+ }
203+
204+ str := strings.TrimSpace(jsonString)
205+ if str == "" {
206+ return "{}"
207+ }
208+
209+ // CONSERVATIVE STRATEGY: First try to parse directly
210+ var testParse interface{}
211+ if err := json.Unmarshal([]byte(str), &testParse); err == nil {
212+ log.Debugf("kiro: repairJSON - JSON is already valid, returning unchanged")
213+ return str
214+ }
215+
216+ log.Debugf("kiro: repairJSON - JSON parse failed, attempting repair")
217+ originalStr := str
218+
219+ // First, escape unescaped newlines/tabs within JSON string values
220+ str = escapeNewlinesInStrings(str)
221+ // Remove trailing commas before closing braces/brackets
222+ str = trailingCommaPattern.ReplaceAllString(str, "$1")
223+
224+ // Calculate bracket balance
225+ braceCount := 0
226+ bracketCount := 0
227+ inString := false
228+ escape := false
229+ lastValidIndex := -1
230+
231+ for i := 0; i < len(str); i++ {
232+ char := str[i]
233+
234+ if escape {
235+ escape = false
236+ continue
237+ }
238+
239+ if char == '\\' {
240+ escape = true
241+ continue
242+ }
243+
244+ if char == '"' {
245+ inString = !inString
246+ continue
247+ }
248+
249+ if inString {
250+ continue
251+ }
252+
253+ switch char {
254+ case '{':
255+ braceCount++
256+ case '}':
257+ braceCount--
258+ case '[':
259+ bracketCount++
260+ case ']':
261+ bracketCount--
262+ }
263+
264+ if braceCount >= 0 && bracketCount >= 0 {
265+ lastValidIndex = i
266+ }
267+ }
268+
269+ // If brackets are unbalanced, try to repair
270+ if braceCount > 0 || bracketCount > 0 {
271+ if lastValidIndex > 0 && lastValidIndex < len(str)-1 {
272+ truncated := str[:lastValidIndex+1]
273+ // Recount brackets after truncation
274+ braceCount = 0
275+ bracketCount = 0
276+ inString = false
277+ escape = false
278+ for i := 0; i < len(truncated); i++ {
279+ char := truncated[i]
280+ if escape {
281+ escape = false
282+ continue
283+ }
284+ if char == '\\' {
285+ escape = true
286+ continue
287+ }
288+ if char == '"' {
289+ inString = !inString
290+ continue
291+ }
292+ if inString {
293+ continue
294+ }
295+ switch char {
296+ case '{':
297+ braceCount++
298+ case '}':
299+ braceCount--
300+ case '[':
301+ bracketCount++
302+ case ']':
303+ bracketCount--
304+ }
305+ }
306+ str = truncated
307+ }
308+
309+ // Add missing closing brackets
310+ for braceCount > 0 {
311+ str += "}"
312+ braceCount--
313+ }
314+ for bracketCount > 0 {
315+ str += "]"
316+ bracketCount--
317+ }
318+ }
319+
320+ // Validate repaired JSON
321+ if err := json.Unmarshal([]byte(str), &testParse); err != nil {
322+ log.Warnf("kiro: repairJSON - repair failed to produce valid JSON, returning original")
323+ return originalStr
324+ }
325+
326+ log.Debugf("kiro: repairJSON - successfully repaired JSON")
327+ return str
328+}
329+
330+// escapeNewlinesInStrings escapes literal newlines, tabs, and other control characters
331+// that appear inside JSON string values.
332+func escapeNewlinesInStrings(raw string) string {
333+ var result strings.Builder
334+ result.Grow(len(raw) + 100)
335+
336+ inString := false
337+ escaped := false
338+
339+ for i := 0; i < len(raw); i++ {
340+ c := raw[i]
341+
342+ if escaped {
343+ result.WriteByte(c)
344+ escaped = false
345+ continue
346+ }
347+
348+ if c == '\\' && inString {
349+ result.WriteByte(c)
350+ escaped = true
351+ continue
352+ }
353+
354+ if c == '"' {
355+ inString = !inString
356+ result.WriteByte(c)
357+ continue
358+ }
359+
360+ if inString {
361+ switch c {
362+ case '\n':
363+ result.WriteString("\\n")
364+ case '\r':
365+ result.WriteString("\\r")
366+ case '\t':
367+ result.WriteString("\\t")
368+ default:
369+ result.WriteByte(c)
370+ }
371+ } else {
372+ result.WriteByte(c)
373+ }
374+ }
375+
376+ return result.String()
377+}
378+
379+// ProcessToolUseEvent handles a toolUseEvent from the Kiro stream.
380+// It accumulates input fragments and emits tool_use blocks when complete.
381+// Returns events to emit and updated state.
382+func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseState, processedIDs map[string]bool) ([]KiroToolUse, *ToolUseState) {
383+ var toolUses []KiroToolUse
384+
385+ // Extract from nested toolUseEvent or direct format
386+ tu := event
387+ if nested, ok := event["toolUseEvent"].(map[string]interface{}); ok {
388+ tu = nested
389+ }
390+
391+ toolUseID := kirocommon.GetString(tu, "toolUseId")
392+ toolName := kirocommon.GetString(tu, "name")
393+ isStop := false
394+ if stop, ok := tu["stop"].(bool); ok {
395+ isStop = stop
396+ }
397+
398+ // Get input - can be string (fragment) or object (complete)
399+ var inputFragment string
400+ var inputMap map[string]interface{}
401+
402+ if inputRaw, ok := tu["input"]; ok {
403+ switch v := inputRaw.(type) {
404+ case string:
405+ inputFragment = v
406+ case map[string]interface{}:
407+ inputMap = v
408+ }
409+ }
410+
411+ // New tool use starting
412+ if toolUseID != "" && toolName != "" {
413+ if currentToolUse != nil && currentToolUse.ToolUseID != toolUseID {
414+ log.Warnf("kiro: interleaved tool use detected - new ID %s arrived while %s in progress, completing previous",
415+ toolUseID, currentToolUse.ToolUseID)
416+ if !processedIDs[currentToolUse.ToolUseID] {
417+ incomplete := KiroToolUse{
418+ ToolUseID: currentToolUse.ToolUseID,
419+ Name: currentToolUse.Name,
420+ }
421+ if currentToolUse.InputBuffer.Len() > 0 {
422+ raw := currentToolUse.InputBuffer.String()
423+ repaired := RepairJSON(raw)
424+
425+ var input map[string]interface{}
426+ if err := json.Unmarshal([]byte(repaired), &input); err != nil {
427+ log.Warnf("kiro: failed to parse interleaved tool input: %v, raw: %s", err, raw)
428+ input = make(map[string]interface{})
429+ }
430+ incomplete.Input = input
431+ }
432+ toolUses = append(toolUses, incomplete)
433+ processedIDs[currentToolUse.ToolUseID] = true
434+ }
435+ currentToolUse = nil
436+ }
437+
438+ if currentToolUse == nil {
439+ if processedIDs != nil && processedIDs[toolUseID] {
440+ log.Debugf("kiro: skipping duplicate toolUseEvent: %s", toolUseID)
441+ return nil, nil
442+ }
443+
444+ currentToolUse = &ToolUseState{
445+ ToolUseID: toolUseID,
446+ Name: toolName,
447+ }
448+ log.Infof("kiro: starting new tool use: %s (ID: %s)", toolName, toolUseID)
449+ }
450+ }
451+
452+ // Accumulate input fragments
453+ if currentToolUse != nil && inputFragment != "" {
454+ currentToolUse.InputBuffer.WriteString(inputFragment)
455+ log.Debugf("kiro: accumulated input fragment, total length: %d", currentToolUse.InputBuffer.Len())
456+ }
457+
458+ // If complete input object provided directly
459+ if currentToolUse != nil && inputMap != nil {
460+ inputBytes, _ := json.Marshal(inputMap)
461+ currentToolUse.InputBuffer.Reset()
462+ currentToolUse.InputBuffer.Write(inputBytes)
463+ }
464+
465+ // Tool use complete
466+ if isStop && currentToolUse != nil {
467+ fullInput := currentToolUse.InputBuffer.String()
468+
469+ // Repair and parse the accumulated JSON
470+ repairedJSON := RepairJSON(fullInput)
471+ var finalInput map[string]interface{}
472+ if err := json.Unmarshal([]byte(repairedJSON), &finalInput); err != nil {
473+ log.Warnf("kiro: failed to parse accumulated tool input: %v, raw: %s", err, fullInput)
474+ finalInput = make(map[string]interface{})
475+ }
476+
477+ toolUse := KiroToolUse{
478+ ToolUseID: currentToolUse.ToolUseID,
479+ Name: currentToolUse.Name,
480+ Input: finalInput,
481+ }
482+ toolUses = append(toolUses, toolUse)
483+
484+ if processedIDs != nil {
485+ processedIDs[currentToolUse.ToolUseID] = true
486+ }
487+
488+ log.Infof("kiro: completed tool use: %s (ID: %s)", currentToolUse.Name, currentToolUse.ToolUseID)
489+ return toolUses, nil
490+ }
491+
492+ return toolUses, currentToolUse
493+}
494+
495+// DeduplicateToolUses removes duplicate tool uses based on toolUseId and content.
496+func DeduplicateToolUses(toolUses []KiroToolUse) []KiroToolUse {
497+ seenIDs := make(map[string]bool)
498+ seenContent := make(map[string]bool)
499+ var unique []KiroToolUse
500+
501+ for _, tu := range toolUses {
502+ if seenIDs[tu.ToolUseID] {
503+ log.Debugf("kiro: removing ID-duplicate tool use: %s (name: %s)", tu.ToolUseID, tu.Name)
504+ continue
505+ }
506+
507+ inputJSON, _ := json.Marshal(tu.Input)
508+ contentKey := tu.Name + ":" + string(inputJSON)
509+
510+ if seenContent[contentKey] {
511+ log.Debugf("kiro: removing content-duplicate tool use: %s (id: %s)", tu.Name, tu.ToolUseID)
512+ continue
513+ }
514+
515+ seenIDs[tu.ToolUseID] = true
516+ seenContent[contentKey] = true
517+ unique = append(unique, tu)
518+ }
519+
520+ return unique
521+}
522+
internal/translator/kiro/common/constants.go+75−0
@@ -0,0 +1,75 @@
1+// Package common provides shared constants and utilities for Kiro translator.
2+package common
3+
4+const (
5+ // KiroMaxToolDescLen is the maximum description length for Kiro API tools.
6+ // Kiro API limit is 10240 bytes, leave room for "..."
7+ KiroMaxToolDescLen = 10237
8+
9+ // ThinkingStartTag is the start tag for thinking blocks in responses.
10+ ThinkingStartTag = "<thinking>"
11+
12+ // ThinkingEndTag is the end tag for thinking blocks in responses.
13+ ThinkingEndTag = "</thinking>"
14+
15+ // CodeFenceMarker is the markdown code fence marker.
16+ CodeFenceMarker = "```"
17+
18+ // AltCodeFenceMarker is the alternative markdown code fence marker.
19+ AltCodeFenceMarker = "~~~"
20+
21+ // InlineCodeMarker is the markdown inline code marker (backtick).
22+ InlineCodeMarker = "`"
23+
24+ // KiroAgenticSystemPrompt is injected only for -agentic models to prevent timeouts on large writes.
25+ // AWS Kiro API has a 2-3 minute timeout for large file write operations.
26+ KiroAgenticSystemPrompt = `
27+# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
28+
29+You MUST follow these rules for ALL file operations. Violation causes server timeouts and task failure.
30+
31+## ABSOLUTE LIMITS
32+- **MAXIMUM 350 LINES** per single write/edit operation - NO EXCEPTIONS
33+- **RECOMMENDED 300 LINES** or less for optimal performance
34+- **NEVER** write entire files in one operation if >300 lines
35+
36+## MANDATORY CHUNKED WRITE STRATEGY
37+
38+### For NEW FILES (>300 lines total):
39+1. FIRST: Write initial chunk (first 250-300 lines) using write_to_file/fsWrite
40+2. THEN: Append remaining content in 250-300 line chunks using file append operations
41+3. REPEAT: Continue appending until complete
42+
43+### For EDITING EXISTING FILES:
44+1. Use surgical edits (apply_diff/targeted edits) - change ONLY what's needed
45+2. NEVER rewrite entire files - use incremental modifications
46+3. Split large refactors into multiple small, focused edits
47+
48+### For LARGE CODE GENERATION:
49+1. Generate in logical sections (imports, types, functions separately)
50+2. Write each section as a separate operation
51+3. Use append operations for subsequent sections
52+
53+## EXAMPLES OF CORRECT BEHAVIOR
54+
55+✅ CORRECT: Writing a 600-line file
56+- Operation 1: Write lines 1-300 (initial file creation)
57+- Operation 2: Append lines 301-600
58+
59+✅ CORRECT: Editing multiple functions
60+- Operation 1: Edit function A
61+- Operation 2: Edit function B
62+- Operation 3: Edit function C
63+
64+❌ WRONG: Writing 500 lines in single operation → TIMEOUT
65+❌ WRONG: Rewriting entire file to change 5 lines → TIMEOUT
66+❌ WRONG: Generating massive code blocks without chunking → TIMEOUT
67+
68+## WHY THIS MATTERS
69+- Server has 2-3 minute timeout for operations
70+- Large writes exceed timeout and FAIL completely
71+- Chunked writes are FASTER and more RELIABLE
72+- Failed writes waste time and require retry
73+
74+REMEMBER: When in doubt, write LESS per operation. Multiple small operations > one large operation.`
75+)
internal/translator/kiro/common/message_merge.go+132−0
@@ -0,0 +1,132 @@
1+// Package common provides shared utilities for Kiro translators.
2+package common
3+
4+import (
5+ "encoding/json"
6+
7+ "github.com/tidwall/gjson"
8+)
9+
10+// MergeAdjacentMessages merges adjacent messages with the same role.
11+// This reduces API call complexity and improves compatibility.
12+// Based on AIClient-2-API implementation.
13+// NOTE: Tool messages are NOT merged because each has a unique tool_call_id that must be preserved.
14+func MergeAdjacentMessages(messages []gjson.Result) []gjson.Result {
15+ if len(messages) <= 1 {
16+ return messages
17+ }
18+
19+ var merged []gjson.Result
20+ for _, msg := range messages {
21+ if len(merged) == 0 {
22+ merged = append(merged, msg)
23+ continue
24+ }
25+
26+ lastMsg := merged[len(merged)-1]
27+ currentRole := msg.Get("role").String()
28+ lastRole := lastMsg.Get("role").String()
29+
30+ // Don't merge tool messages - each has a unique tool_call_id
31+ if currentRole == "tool" || lastRole == "tool" {
32+ merged = append(merged, msg)
33+ continue
34+ }
35+
36+ if currentRole == lastRole {
37+ // Merge content from current message into last message
38+ mergedContent := mergeMessageContent(lastMsg, msg)
39+ // Create a new merged message JSON
40+ mergedMsg := createMergedMessage(lastRole, mergedContent)
41+ merged[len(merged)-1] = gjson.Parse(mergedMsg)
42+ } else {
43+ merged = append(merged, msg)
44+ }
45+ }
46+
47+ return merged
48+}
49+
50+// mergeMessageContent merges the content of two messages with the same role.
51+// Handles both string content and array content (with text, tool_use, tool_result blocks).
52+func mergeMessageContent(msg1, msg2 gjson.Result) string {
53+ content1 := msg1.Get("content")
54+ content2 := msg2.Get("content")
55+
56+ // Extract content blocks from both messages
57+ var blocks1, blocks2 []map[string]interface{}
58+
59+ if content1.IsArray() {
60+ for _, block := range content1.Array() {
61+ blocks1 = append(blocks1, blockToMap(block))
62+ }
63+ } else if content1.Type == gjson.String {
64+ blocks1 = append(blocks1, map[string]interface{}{
65+ "type": "text",
66+ "text": content1.String(),
67+ })
68+ }
69+
70+ if content2.IsArray() {
71+ for _, block := range content2.Array() {
72+ blocks2 = append(blocks2, blockToMap(block))
73+ }
74+ } else if content2.Type == gjson.String {
75+ blocks2 = append(blocks2, map[string]interface{}{
76+ "type": "text",
77+ "text": content2.String(),
78+ })
79+ }
80+
81+ // Merge text blocks if both end/start with text
82+ if len(blocks1) > 0 && len(blocks2) > 0 {
83+ if blocks1[len(blocks1)-1]["type"] == "text" && blocks2[0]["type"] == "text" {
84+ // Merge the last text block of msg1 with the first text block of msg2
85+ text1 := blocks1[len(blocks1)-1]["text"].(string)
86+ text2 := blocks2[0]["text"].(string)
87+ blocks1[len(blocks1)-1]["text"] = text1 + "\n" + text2
88+ blocks2 = blocks2[1:] // Remove the merged block from blocks2
89+ }
90+ }
91+
92+ // Combine all blocks
93+ allBlocks := append(blocks1, blocks2...)
94+
95+ // Convert to JSON
96+ result, _ := json.Marshal(allBlocks)
97+ return string(result)
98+}
99+
100+// blockToMap converts a gjson.Result block to a map[string]interface{}
101+func blockToMap(block gjson.Result) map[string]interface{} {
102+ result := make(map[string]interface{})
103+ block.ForEach(func(key, value gjson.Result) bool {
104+ if value.IsObject() {
105+ result[key.String()] = blockToMap(value)
106+ } else if value.IsArray() {
107+ var arr []interface{}
108+ for _, item := range value.Array() {
109+ if item.IsObject() {
110+ arr = append(arr, blockToMap(item))
111+ } else {
112+ arr = append(arr, item.Value())
113+ }
114+ }
115+ result[key.String()] = arr
116+ } else {
117+ result[key.String()] = value.Value()
118+ }
119+ return true
120+ })
121+ return result
122+}
123+
124+// createMergedMessage creates a JSON string for a merged message
125+func createMergedMessage(role string, content string) string {
126+ msg := map[string]interface{}{
127+ "role": role,
128+ "content": json.RawMessage(content),
129+ }
130+ result, _ := json.Marshal(msg)
131+ return string(result)
132+}
internal/translator/kiro/common/utils.go+16−0
@@ -0,0 +1,16 @@
1+// Package common provides shared constants and utilities for Kiro translator.
2+package common
3+
4+// GetString safely extracts a string from a map.
5+// Returns empty string if the key doesn't exist or the value is not a string.
6+func GetString(m map[string]interface{}, key string) string {
7+ if v, ok := m[key].(string); ok {
8+ return v
9+ }
10+ return ""
11+}
12+
13+// GetStringValue is an alias for GetString for backward compatibility.
14+func GetStringValue(m map[string]interface{}, key string) string {
15+ return GetString(m, key)
16+}
internal/translator/kiro/openai/init.go+20−0
@@ -0,0 +1,20 @@
1+// Package openai provides translation between OpenAI Chat Completions and Kiro formats.
2+package openai
3+
4+import (
5+ . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
6+ "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
7+ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
8+)
9+
10+func init() {
11+ translator.Register(
12+ OpenAI, // source format
13+ Kiro, // target format
14+ ConvertOpenAIRequestToKiro,
15+ interfaces.TranslateResponse{
16+ Stream: ConvertKiroStreamToOpenAI,
17+ NonStream: ConvertKiroNonStreamToOpenAI,
18+ },
19+ )
20+}
internal/translator/kiro/openai/kiro_openai.go+371−0
@@ -0,0 +1,371 @@
1+// Package openai provides translation between OpenAI Chat Completions and Kiro formats.
2+// This package enables direct OpenAI → Kiro translation, bypassing the Claude intermediate layer.
3+//
4+// The Kiro executor generates Claude-compatible SSE format internally, so the streaming response
5+// translation converts from Claude SSE format to OpenAI SSE format.
6+package openai
7+
8+import (
9+ "bytes"
10+ "context"
11+ "encoding/json"
12+ "strings"
13+
14+ kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
15+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
16+ log "github.com/sirupsen/logrus"
17+ "github.com/tidwall/gjson"
18+)
19+
20+// ConvertKiroStreamToOpenAI converts Kiro streaming response to OpenAI format.
21+// The Kiro executor emits Claude-compatible SSE events, so this function translates
22+// from Claude SSE format to OpenAI SSE format.
23+//
24+// Claude SSE format:
25+// - event: message_start\ndata: {...}
26+// - event: content_block_start\ndata: {...}
27+// - event: content_block_delta\ndata: {...}
28+// - event: content_block_stop\ndata: {...}
29+// - event: message_delta\ndata: {...}
30+// - event: message_stop\ndata: {...}
31+//
32+// OpenAI SSE format:
33+// - data: {"id":"...","object":"chat.completion.chunk",...}
34+// - data: [DONE]
35+func ConvertKiroStreamToOpenAI(ctx context.Context, model string, originalRequest, request, rawResponse []byte, param *any) []string {
36+ // Initialize state if needed
37+ if *param == nil {
38+ *param = NewOpenAIStreamState(model)
39+ }
40+ state := (*param).(*OpenAIStreamState)
41+
42+ // Parse the Claude SSE event
43+ responseStr := string(rawResponse)
44+
45+ // Handle raw event format (event: xxx\ndata: {...})
46+ var eventType string
47+ var eventData string
48+
49+ if strings.HasPrefix(responseStr, "event:") {
50+ // Parse event type and data
51+ lines := strings.SplitN(responseStr, "\n", 2)
52+ if len(lines) >= 1 {
53+ eventType = strings.TrimSpace(strings.TrimPrefix(lines[0], "event:"))
54+ }
55+ if len(lines) >= 2 && strings.HasPrefix(lines[1], "data:") {
56+ eventData = strings.TrimSpace(strings.TrimPrefix(lines[1], "data:"))
57+ }
58+ } else if strings.HasPrefix(responseStr, "data:") {
59+ // Just data line
60+ eventData = strings.TrimSpace(strings.TrimPrefix(responseStr, "data:"))
61+ } else {
62+ // Try to parse as raw JSON
63+ eventData = strings.TrimSpace(responseStr)
64+ }
65+
66+ if eventData == "" {
67+ return []string{}
68+ }
69+
70+ // Parse the event data as JSON
71+ eventJSON := gjson.Parse(eventData)
72+ if !eventJSON.Exists() {
73+ return []string{}
74+ }
75+
76+ // Determine event type from JSON if not already set
77+ if eventType == "" {
78+ eventType = eventJSON.Get("type").String()
79+ }
80+
81+ var results []string
82+
83+ switch eventType {
84+ case "message_start":
85+ // Send first chunk with role
86+ firstChunk := BuildOpenAISSEFirstChunk(state)
87+ results = append(results, firstChunk)
88+
89+ case "content_block_start":
90+ // Check block type
91+ blockType := eventJSON.Get("content_block.type").String()
92+ switch blockType {
93+ case "text":
94+ // Text block starting - nothing to emit yet
95+ case "thinking":
96+ // Thinking block starting - nothing to emit yet for OpenAI
97+ case "tool_use":
98+ // Tool use block starting
99+ toolUseID := eventJSON.Get("content_block.id").String()
100+ toolName := eventJSON.Get("content_block.name").String()
101+ chunk := BuildOpenAISSEToolCallStart(state, toolUseID, toolName)
102+ results = append(results, chunk)
103+ state.ToolCallIndex++
104+ }
105+
106+ case "content_block_delta":
107+ deltaType := eventJSON.Get("delta.type").String()
108+ switch deltaType {
109+ case "text_delta":
110+ textDelta := eventJSON.Get("delta.text").String()
111+ if textDelta != "" {
112+ chunk := BuildOpenAISSETextDelta(state, textDelta)
113+ results = append(results, chunk)
114+ }
115+ case "thinking_delta":
116+ // Convert thinking to reasoning_content for o1-style compatibility
117+ thinkingDelta := eventJSON.Get("delta.thinking").String()
118+ if thinkingDelta != "" {
119+ chunk := BuildOpenAISSEReasoningDelta(state, thinkingDelta)
120+ results = append(results, chunk)
121+ }
122+ case "input_json_delta":
123+ // Tool call arguments delta
124+ partialJSON := eventJSON.Get("delta.partial_json").String()
125+ if partialJSON != "" {
126+ // Get the tool index from content block index
127+ blockIndex := int(eventJSON.Get("index").Int())
128+ chunk := BuildOpenAISSEToolCallArgumentsDelta(state, partialJSON, blockIndex-1) // Adjust for 0-based tool index
129+ results = append(results, chunk)
130+ }
131+ }
132+
133+ case "content_block_stop":
134+ // Content block ended - nothing to emit for OpenAI
135+
136+ case "message_delta":
137+ // Message delta with stop_reason
138+ stopReason := eventJSON.Get("delta.stop_reason").String()
139+ finishReason := mapKiroStopReasonToOpenAI(stopReason)
140+ if finishReason != "" {
141+ chunk := BuildOpenAISSEFinish(state, finishReason)
142+ results = append(results, chunk)
143+ }
144+
145+ // Extract usage if present
146+ if eventJSON.Get("usage").Exists() {
147+ inputTokens := eventJSON.Get("usage.input_tokens").Int()
148+ outputTokens := eventJSON.Get("usage.output_tokens").Int()
149+ usageInfo := usage.Detail{
150+ InputTokens: inputTokens,
151+ OutputTokens: outputTokens,
152+ TotalTokens: inputTokens + outputTokens,
153+ }
154+ chunk := BuildOpenAISSEUsage(state, usageInfo)
155+ results = append(results, chunk)
156+ }
157+
158+ case "message_stop":
159+ // Final event - do NOT emit [DONE] here
160+ // The handler layer (openai_handlers.go) will send [DONE] when the stream closes
161+ // Emitting [DONE] here would cause duplicate [DONE] markers
162+
163+ case "ping":
164+ // Ping event with usage - optionally emit usage chunk
165+ if eventJSON.Get("usage").Exists() {
166+ inputTokens := eventJSON.Get("usage.input_tokens").Int()
167+ outputTokens := eventJSON.Get("usage.output_tokens").Int()
168+ usageInfo := usage.Detail{
169+ InputTokens: inputTokens,
170+ OutputTokens: outputTokens,
171+ TotalTokens: inputTokens + outputTokens,
172+ }
173+ chunk := BuildOpenAISSEUsage(state, usageInfo)
174+ results = append(results, chunk)
175+ }
176+ }
177+
178+ return results
179+}
180+
181+// ConvertKiroNonStreamToOpenAI converts Kiro non-streaming response to OpenAI format.
182+// The Kiro executor returns Claude-compatible JSON responses, so this function translates
183+// from Claude format to OpenAI format.
184+func ConvertKiroNonStreamToOpenAI(ctx context.Context, model string, originalRequest, request, rawResponse []byte, param *any) string {
185+ // Parse the Claude-format response
186+ response := gjson.ParseBytes(rawResponse)
187+
188+ // Extract content
189+ var content string
190+ var reasoningContent string
191+ var toolUses []KiroToolUse
192+ var stopReason string
193+
194+ // Get stop_reason
195+ stopReason = response.Get("stop_reason").String()
196+
197+ // Process content blocks
198+ contentBlocks := response.Get("content")
199+ if contentBlocks.IsArray() {
200+ for _, block := range contentBlocks.Array() {
201+ blockType := block.Get("type").String()
202+ switch blockType {
203+ case "text":
204+ content += block.Get("text").String()
205+ case "thinking":
206+ // Convert thinking blocks to reasoning_content for OpenAI format
207+ reasoningContent += block.Get("thinking").String()
208+ case "tool_use":
209+ toolUseID := block.Get("id").String()
210+ toolName := block.Get("name").String()
211+ toolInput := block.Get("input")
212+
213+ var inputMap map[string]interface{}
214+ if toolInput.IsObject() {
215+ inputMap = make(map[string]interface{})
216+ toolInput.ForEach(func(key, value gjson.Result) bool {
217+ inputMap[key.String()] = value.Value()
218+ return true
219+ })
220+ }
221+
222+ toolUses = append(toolUses, KiroToolUse{
223+ ToolUseID: toolUseID,
224+ Name: toolName,
225+ Input: inputMap,
226+ })
227+ }
228+ }
229+ }
230+
231+ // Extract usage
232+ usageInfo := usage.Detail{
233+ InputTokens: response.Get("usage.input_tokens").Int(),
234+ OutputTokens: response.Get("usage.output_tokens").Int(),
235+ }
236+ usageInfo.TotalTokens = usageInfo.InputTokens + usageInfo.OutputTokens
237+
238+ // Build OpenAI response with reasoning_content support
239+ openaiResponse := BuildOpenAIResponseWithReasoning(content, reasoningContent, toolUses, model, usageInfo, stopReason)
240+ return string(openaiResponse)
241+}
242+
243+// ParseClaudeEvent parses a Claude SSE event and returns the event type and data
244+func ParseClaudeEvent(rawEvent []byte) (eventType string, eventData []byte) {
245+ lines := bytes.Split(rawEvent, []byte("\n"))
246+ for _, line := range lines {
247+ line = bytes.TrimSpace(line)
248+ if bytes.HasPrefix(line, []byte("event:")) {
249+ eventType = string(bytes.TrimSpace(bytes.TrimPrefix(line, []byte("event:"))))
250+ } else if bytes.HasPrefix(line, []byte("data:")) {
251+ eventData = bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
252+ }
253+ }
254+ return eventType, eventData
255+}
256+
257+// ExtractThinkingFromContent parses content to extract thinking blocks.
258+// Returns cleaned content (without thinking tags) and whether thinking was found.
259+func ExtractThinkingFromContent(content string) (string, string, bool) {
260+ if !strings.Contains(content, kirocommon.ThinkingStartTag) {
261+ return content, "", false
262+ }
263+
264+ var cleanedContent strings.Builder
265+ var thinkingContent strings.Builder
266+ hasThinking := false
267+ remaining := content
268+
269+ for len(remaining) > 0 {
270+ startIdx := strings.Index(remaining, kirocommon.ThinkingStartTag)
271+ if startIdx == -1 {
272+ cleanedContent.WriteString(remaining)
273+ break
274+ }
275+
276+ // Add content before thinking tag
277+ cleanedContent.WriteString(remaining[:startIdx])
278+
279+ // Move past opening tag
280+ remaining = remaining[startIdx+len(kirocommon.ThinkingStartTag):]
281+
282+ // Find closing tag
283+ endIdx := strings.Index(remaining, kirocommon.ThinkingEndTag)
284+ if endIdx == -1 {
285+ // No closing tag - treat rest as thinking
286+ thinkingContent.WriteString(remaining)
287+ hasThinking = true
288+ break
289+ }
290+
291+ // Extract thinking content
292+ thinkingContent.WriteString(remaining[:endIdx])
293+ hasThinking = true
294+ remaining = remaining[endIdx+len(kirocommon.ThinkingEndTag):]
295+ }
296+
297+ return strings.TrimSpace(cleanedContent.String()), strings.TrimSpace(thinkingContent.String()), hasThinking
298+}
299+
300+// ConvertOpenAIToolsToKiroFormat is a helper that converts OpenAI tools format to Kiro format
301+func ConvertOpenAIToolsToKiroFormat(tools []map[string]interface{}) []KiroToolWrapper {
302+ var kiroTools []KiroToolWrapper
303+
304+ for _, tool := range tools {
305+ toolType, _ := tool["type"].(string)
306+ if toolType != "function" {
307+ continue
308+ }
309+
310+ fn, ok := tool["function"].(map[string]interface{})
311+ if !ok {
312+ continue
313+ }
314+
315+ name := kirocommon.GetString(fn, "name")
316+ description := kirocommon.GetString(fn, "description")
317+ parameters := fn["parameters"]
318+
319+ if name == "" {
320+ continue
321+ }
322+
323+ if description == "" {
324+ description = "Tool: " + name
325+ }
326+
327+ kiroTools = append(kiroTools, KiroToolWrapper{
328+ ToolSpecification: KiroToolSpecification{
329+ Name: name,
330+ Description: description,
331+ InputSchema: KiroInputSchema{JSON: parameters},
332+ },
333+ })
334+ }
335+
336+ return kiroTools
337+}
338+
339+// OpenAIStreamParams holds parameters for OpenAI streaming conversion
340+type OpenAIStreamParams struct {
341+ State *OpenAIStreamState
342+ ThinkingState *ThinkingTagState
343+ ToolCallsEmitted map[string]bool
344+}
345+
346+// NewOpenAIStreamParams creates new streaming parameters
347+func NewOpenAIStreamParams(model string) *OpenAIStreamParams {
348+ return &OpenAIStreamParams{
349+ State: NewOpenAIStreamState(model),
350+ ThinkingState: NewThinkingTagState(),
351+ ToolCallsEmitted: make(map[string]bool),
352+ }
353+}
354+
355+// ConvertClaudeToolUseToOpenAI converts a Claude tool_use block to OpenAI tool_calls format
356+func ConvertClaudeToolUseToOpenAI(toolUseID, toolName string, input map[string]interface{}) map[string]interface{} {
357+ inputJSON, _ := json.Marshal(input)
358+ return map[string]interface{}{
359+ "id": toolUseID,
360+ "type": "function",
361+ "function": map[string]interface{}{
362+ "name": toolName,
363+ "arguments": string(inputJSON),
364+ },
365+ }
366+}
367+
368+// LogStreamEvent logs a streaming event for debugging
369+func LogStreamEvent(eventType, data string) {
370+ log.Debugf("kiro-openai: stream event type=%s, data_len=%d", eventType, len(data))
371+}
internal/translator/kiro/openai/kiro_openai_request.go+863−0
@@ -0,0 +1,863 @@
1+// Package openai provides request translation from OpenAI Chat Completions to Kiro format.
2+// It handles parsing and transforming OpenAI API requests into the Kiro/Amazon Q API format,
3+// extracting model information, system instructions, message contents, and tool declarations.
4+package openai
5+
6+import (
7+ "encoding/json"
8+ "fmt"
9+ "net/http"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/google/uuid"
15+ kiroclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/claude"
16+ kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
17+ log "github.com/sirupsen/logrus"
18+ "github.com/tidwall/gjson"
19+)
20+
21+// Kiro API request structs - reuse from kiroclaude package structure
22+
23+// KiroPayload is the top-level request structure for Kiro API
24+type KiroPayload struct {
25+ ConversationState KiroConversationState `json:"conversationState"`
26+ ProfileArn string `json:"profileArn,omitempty"`
27+ InferenceConfig *KiroInferenceConfig `json:"inferenceConfig,omitempty"`
28+}
29+
30+// KiroInferenceConfig contains inference parameters for the Kiro API.
31+type KiroInferenceConfig struct {
32+ MaxTokens int `json:"maxTokens,omitempty"`
33+ Temperature float64 `json:"temperature,omitempty"`
34+ TopP float64 `json:"topP,omitempty"`
35+}
36+
37+// KiroConversationState holds the conversation context
38+type KiroConversationState struct {
39+ ChatTriggerType string `json:"chatTriggerType"` // Required: "MANUAL"
40+ ConversationID string `json:"conversationId"`
41+ CurrentMessage KiroCurrentMessage `json:"currentMessage"`
42+ History []KiroHistoryMessage `json:"history,omitempty"`
43+}
44+
45+// KiroCurrentMessage wraps the current user message
46+type KiroCurrentMessage struct {
47+ UserInputMessage KiroUserInputMessage `json:"userInputMessage"`
48+}
49+
50+// KiroHistoryMessage represents a message in the conversation history
51+type KiroHistoryMessage struct {
52+ UserInputMessage *KiroUserInputMessage `json:"userInputMessage,omitempty"`
53+ AssistantResponseMessage *KiroAssistantResponseMessage `json:"assistantResponseMessage,omitempty"`
54+}
55+
56+// KiroImage represents an image in Kiro API format
57+type KiroImage struct {
58+ Format string `json:"format"`
59+ Source KiroImageSource `json:"source"`
60+}
61+
62+// KiroImageSource contains the image data
63+type KiroImageSource struct {
64+ Bytes string `json:"bytes"` // base64 encoded image data
65+}
66+
67+// KiroUserInputMessage represents a user message
68+type KiroUserInputMessage struct {
69+ Content string `json:"content"`
70+ ModelID string `json:"modelId"`
71+ Origin string `json:"origin"`
72+ Images []KiroImage `json:"images,omitempty"`
73+ UserInputMessageContext *KiroUserInputMessageContext `json:"userInputMessageContext,omitempty"`
74+}
75+
76+// KiroUserInputMessageContext contains tool-related context
77+type KiroUserInputMessageContext struct {
78+ ToolResults []KiroToolResult `json:"toolResults,omitempty"`
79+ Tools []KiroToolWrapper `json:"tools,omitempty"`
80+}
81+
82+// KiroToolResult represents a tool execution result
83+type KiroToolResult struct {
84+ Content []KiroTextContent `json:"content"`
85+ Status string `json:"status"`
86+ ToolUseID string `json:"toolUseId"`
87+}
88+
89+// KiroTextContent represents text content
90+type KiroTextContent struct {
91+ Text string `json:"text"`
92+}
93+
94+// KiroToolWrapper wraps a tool specification
95+type KiroToolWrapper struct {
96+ ToolSpecification KiroToolSpecification `json:"toolSpecification"`
97+}
98+
99+// KiroToolSpecification defines a tool's schema
100+type KiroToolSpecification struct {
101+ Name string `json:"name"`
102+ Description string `json:"description"`
103+ InputSchema KiroInputSchema `json:"inputSchema"`
104+}
105+
106+// KiroInputSchema wraps the JSON schema for tool input
107+type KiroInputSchema struct {
108+ JSON interface{} `json:"json"`
109+}
110+
111+// KiroAssistantResponseMessage represents an assistant message
112+type KiroAssistantResponseMessage struct {
113+ Content string `json:"content"`
114+ ToolUses []KiroToolUse `json:"toolUses,omitempty"`
115+}
116+
117+// KiroToolUse represents a tool invocation by the assistant
118+type KiroToolUse struct {
119+ ToolUseID string `json:"toolUseId"`
120+ Name string `json:"name"`
121+ Input map[string]interface{} `json:"input"`
122+}
123+
124+// ConvertOpenAIRequestToKiro converts an OpenAI Chat Completions request to Kiro format.
125+// This is the main entry point for request translation.
126+// Note: The actual payload building happens in the executor, this just passes through
127+// the OpenAI format which will be converted by BuildKiroPayloadFromOpenAI.
128+func ConvertOpenAIRequestToKiro(modelName string, inputRawJSON []byte, stream bool) []byte {
129+ // Pass through the OpenAI format - actual conversion happens in BuildKiroPayloadFromOpenAI
130+ return inputRawJSON
131+}
132+
133+// BuildKiroPayloadFromOpenAI constructs the Kiro API request payload from OpenAI format.
134+// Supports tool calling - tools are passed via userInputMessageContext.
135+// origin parameter determines which quota to use: "CLI" for Amazon Q, "AI_EDITOR" for Kiro IDE.
136+// isAgentic parameter enables chunked write optimization prompt for -agentic model variants.
137+// isChatOnly parameter disables tool calling for -chat model variants (pure conversation mode).
138+// headers parameter allows checking Anthropic-Beta header for thinking mode detection.
139+// metadata parameter is kept for API compatibility but no longer used for thinking configuration.
140+// Returns the payload and a boolean indicating whether thinking mode was injected.
141+func BuildKiroPayloadFromOpenAI(openaiBody []byte, modelID, profileArn, origin string, isAgentic, isChatOnly bool, headers http.Header, metadata map[string]any) ([]byte, bool) {
142+ // Extract max_tokens for potential use in inferenceConfig
143+ // Handle -1 as "use maximum" (Kiro max output is ~32000 tokens)
144+ const kiroMaxOutputTokens = 32000
145+ var maxTokens int64
146+ if mt := gjson.GetBytes(openaiBody, "max_tokens"); mt.Exists() {
147+ maxTokens = mt.Int()
148+ if maxTokens == -1 {
149+ maxTokens = kiroMaxOutputTokens
150+ log.Debugf("kiro-openai: max_tokens=-1 converted to %d", kiroMaxOutputTokens)
151+ }
152+ }
153+
154+ // Extract temperature if specified
155+ var temperature float64
156+ var hasTemperature bool
157+ if temp := gjson.GetBytes(openaiBody, "temperature"); temp.Exists() {
158+ temperature = temp.Float()
159+ hasTemperature = true
160+ }
161+
162+ // Extract top_p if specified
163+ var topP float64
164+ var hasTopP bool
165+ if tp := gjson.GetBytes(openaiBody, "top_p"); tp.Exists() {
166+ topP = tp.Float()
167+ hasTopP = true
168+ log.Debugf("kiro-openai: extracted top_p: %.2f", topP)
169+ }
170+
171+ // Normalize origin value for Kiro API compatibility
172+ origin = normalizeOrigin(origin)
173+ log.Debugf("kiro-openai: normalized origin value: %s", origin)
174+
175+ messages := gjson.GetBytes(openaiBody, "messages")
176+
177+ // For chat-only mode, don't include tools
178+ var tools gjson.Result
179+ if !isChatOnly {
180+ tools = gjson.GetBytes(openaiBody, "tools")
181+ }
182+
183+ // Extract system prompt from messages
184+ systemPrompt := extractSystemPromptFromOpenAI(messages)
185+
186+ // Inject timestamp context
187+ timestamp := time.Now().Format("2006-01-02 15:04:05 MST")
188+ timestampContext := fmt.Sprintf("[Context: Current time is %s]", timestamp)
189+ if systemPrompt != "" {
190+ systemPrompt = timestampContext + "\n\n" + systemPrompt
191+ } else {
192+ systemPrompt = timestampContext
193+ }
194+ log.Debugf("kiro-openai: injected timestamp context: %s", timestamp)
195+
196+ // Inject agentic optimization prompt for -agentic model variants
197+ if isAgentic {
198+ if systemPrompt != "" {
199+ systemPrompt += "\n"
200+ }
201+ systemPrompt += kirocommon.KiroAgenticSystemPrompt
202+ }
203+
204+ // Handle tool_choice parameter - Kiro doesn't support it natively, so we inject system prompt hints
205+ // OpenAI tool_choice values: "none", "auto", "required", or {"type":"function","function":{"name":"..."}}
206+ toolChoiceHint := extractToolChoiceHint(openaiBody)
207+ if toolChoiceHint != "" {
208+ if systemPrompt != "" {
209+ systemPrompt += "\n"
210+ }
211+ systemPrompt += toolChoiceHint
212+ log.Debugf("kiro-openai: injected tool_choice hint into system prompt")
213+ }
214+
215+ // Handle response_format parameter - Kiro doesn't support it natively, so we inject system prompt hints
216+ // OpenAI response_format: {"type": "json_object"} or {"type": "json_schema", "json_schema": {...}}
217+ responseFormatHint := extractResponseFormatHint(openaiBody)
218+ if responseFormatHint != "" {
219+ if systemPrompt != "" {
220+ systemPrompt += "\n"
221+ }
222+ systemPrompt += responseFormatHint
223+ log.Debugf("kiro-openai: injected response_format hint into system prompt")
224+ }
225+
226+ // Check for thinking mode
227+ // Supports OpenAI reasoning_effort parameter, model name hints, and Anthropic-Beta header
228+ thinkingEnabled := checkThinkingModeFromOpenAIWithHeaders(openaiBody, headers)
229+
230+ // Convert OpenAI tools to Kiro format
231+ kiroTools := convertOpenAIToolsToKiro(tools)
232+
233+ // Thinking mode implementation:
234+ // Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
235+ // When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
236+ // rather than inline <thinking> tags in assistantResponseEvent.
237+ // We use a high max_thinking_length to allow extensive reasoning.
238+ if thinkingEnabled {
239+ thinkingHint := `<thinking_mode>enabled</thinking_mode>
240+<max_thinking_length>200000</max_thinking_length>`
241+ if systemPrompt != "" {
242+ systemPrompt = thinkingHint + "\n\n" + systemPrompt
243+ } else {
244+ systemPrompt = thinkingHint
245+ }
246+ log.Debugf("kiro-openai: injected thinking prompt (official mode)")
247+ }
248+
249+ // Process messages and build history
250+ history, currentUserMsg, currentToolResults := processOpenAIMessages(messages, modelID, origin)
251+
252+ // Build content with system prompt
253+ if currentUserMsg != nil {
254+ currentUserMsg.Content = buildFinalContent(currentUserMsg.Content, systemPrompt, currentToolResults)
255+
256+ // Deduplicate currentToolResults
257+ currentToolResults = deduplicateToolResults(currentToolResults)
258+
259+ // Build userInputMessageContext with tools and tool results
260+ if len(kiroTools) > 0 || len(currentToolResults) > 0 {
261+ currentUserMsg.UserInputMessageContext = &KiroUserInputMessageContext{
262+ Tools: kiroTools,
263+ ToolResults: currentToolResults,
264+ }
265+ }
266+ }
267+
268+ // Build payload
269+ var currentMessage KiroCurrentMessage
270+ if currentUserMsg != nil {
271+ currentMessage = KiroCurrentMessage{UserInputMessage: *currentUserMsg}
272+ } else {
273+ fallbackContent := ""
274+ if systemPrompt != "" {
275+ fallbackContent = "--- SYSTEM PROMPT ---\n" + systemPrompt + "\n--- END SYSTEM PROMPT ---\n"
276+ }
277+ currentMessage = KiroCurrentMessage{UserInputMessage: KiroUserInputMessage{
278+ Content: fallbackContent,
279+ ModelID: modelID,
280+ Origin: origin,
281+ }}
282+ }
283+
284+ // Build inferenceConfig if we have any inference parameters
285+ // Note: Kiro API doesn't actually use max_tokens for thinking budget
286+ var inferenceConfig *KiroInferenceConfig
287+ if maxTokens > 0 || hasTemperature || hasTopP {
288+ inferenceConfig = &KiroInferenceConfig{}
289+ if maxTokens > 0 {
290+ inferenceConfig.MaxTokens = int(maxTokens)
291+ }
292+ if hasTemperature {
293+ inferenceConfig.Temperature = temperature
294+ }
295+ if hasTopP {
296+ inferenceConfig.TopP = topP
297+ }
298+ }
299+
300+ payload := KiroPayload{
301+ ConversationState: KiroConversationState{
302+ ChatTriggerType: "MANUAL",
303+ ConversationID: uuid.New().String(),
304+ CurrentMessage: currentMessage,
305+ History: history,
306+ },
307+ ProfileArn: profileArn,
308+ InferenceConfig: inferenceConfig,
309+ }
310+
311+ result, err := json.Marshal(payload)
312+ if err != nil {
313+ log.Debugf("kiro-openai: failed to marshal payload: %v", err)
314+ return nil, false
315+ }
316+
317+ return result, thinkingEnabled
318+}
319+
320+// normalizeOrigin normalizes origin value for Kiro API compatibility
321+func normalizeOrigin(origin string) string {
322+ switch origin {
323+ case "KIRO_CLI":
324+ return "CLI"
325+ case "KIRO_AI_EDITOR":
326+ return "AI_EDITOR"
327+ case "AMAZON_Q":
328+ return "CLI"
329+ case "KIRO_IDE":
330+ return "AI_EDITOR"
331+ default:
332+ return origin
333+ }
334+}
335+
336+// extractSystemPromptFromOpenAI extracts system prompt from OpenAI messages
337+func extractSystemPromptFromOpenAI(messages gjson.Result) string {
338+ if !messages.IsArray() {
339+ return ""
340+ }
341+
342+ var systemParts []string
343+ for _, msg := range messages.Array() {
344+ if msg.Get("role").String() == "system" {
345+ content := msg.Get("content")
346+ if content.Type == gjson.String {
347+ systemParts = append(systemParts, content.String())
348+ } else if content.IsArray() {
349+ // Handle array content format
350+ for _, part := range content.Array() {
351+ if part.Get("type").String() == "text" {
352+ systemParts = append(systemParts, part.Get("text").String())
353+ }
354+ }
355+ }
356+ }
357+ }
358+
359+ return strings.Join(systemParts, "\n")
360+}
361+
362+// shortenToolNameIfNeeded shortens tool names that exceed 64 characters.
363+// MCP tools often have long names like "mcp__server-name__tool-name".
364+// This preserves the "mcp__" prefix and last segment when possible.
365+func shortenToolNameIfNeeded(name string) string {
366+ const limit = 64
367+ if len(name) <= limit {
368+ return name
369+ }
370+ // For MCP tools, try to preserve prefix and last segment
371+ if strings.HasPrefix(name, "mcp__") {
372+ idx := strings.LastIndex(name, "__")
373+ if idx > 0 {
374+ cand := "mcp__" + name[idx+2:]
375+ if len(cand) > limit {
376+ return cand[:limit]
377+ }
378+ return cand
379+ }
380+ }
381+ return name[:limit]
382+}
383+
384+// convertOpenAIToolsToKiro converts OpenAI tools to Kiro format
385+func convertOpenAIToolsToKiro(tools gjson.Result) []KiroToolWrapper {
386+ var kiroTools []KiroToolWrapper
387+ if !tools.IsArray() {
388+ return kiroTools
389+ }
390+
391+ for _, tool := range tools.Array() {
392+ // OpenAI tools have type "function" with function definition inside
393+ if tool.Get("type").String() != "function" {
394+ continue
395+ }
396+
397+ fn := tool.Get("function")
398+ if !fn.Exists() {
399+ continue
400+ }
401+
402+ name := fn.Get("name").String()
403+ description := fn.Get("description").String()
404+ parameters := fn.Get("parameters").Value()
405+
406+ // Shorten tool name if it exceeds 64 characters (common with MCP tools)
407+ originalName := name
408+ name = shortenToolNameIfNeeded(name)
409+ if name != originalName {
410+ log.Debugf("kiro-openai: shortened tool name from '%s' to '%s'", originalName, name)
411+ }
412+
413+ // CRITICAL FIX: Kiro API requires non-empty description
414+ if strings.TrimSpace(description) == "" {
415+ description = fmt.Sprintf("Tool: %s", name)
416+ log.Debugf("kiro-openai: tool '%s' has empty description, using default: %s", name, description)
417+ }
418+
419+ // Truncate long descriptions
420+ if len(description) > kirocommon.KiroMaxToolDescLen {
421+ truncLen := kirocommon.KiroMaxToolDescLen - 30
422+ for truncLen > 0 && !utf8.RuneStart(description[truncLen]) {
423+ truncLen--
424+ }
425+ description = description[:truncLen] + "... (description truncated)"
426+ }
427+
428+ kiroTools = append(kiroTools, KiroToolWrapper{
429+ ToolSpecification: KiroToolSpecification{
430+ Name: name,
431+ Description: description,
432+ InputSchema: KiroInputSchema{JSON: parameters},
433+ },
434+ })
435+ }
436+
437+ return kiroTools
438+}
439+
440+// processOpenAIMessages processes OpenAI messages and builds Kiro history
441+func processOpenAIMessages(messages gjson.Result, modelID, origin string) ([]KiroHistoryMessage, *KiroUserInputMessage, []KiroToolResult) {
442+ var history []KiroHistoryMessage
443+ var currentUserMsg *KiroUserInputMessage
444+ var currentToolResults []KiroToolResult
445+
446+ if !messages.IsArray() {
447+ return history, currentUserMsg, currentToolResults
448+ }
449+
450+ // Merge adjacent messages with the same role
451+ messagesArray := kirocommon.MergeAdjacentMessages(messages.Array())
452+
453+ // Track pending tool results that should be attached to the next user message
454+ // This is critical for LiteLLM-translated requests where tool results appear
455+ // as separate "tool" role messages between assistant and user messages
456+ var pendingToolResults []KiroToolResult
457+
458+ for i, msg := range messagesArray {
459+ role := msg.Get("role").String()
460+ isLastMessage := i == len(messagesArray)-1
461+
462+ switch role {
463+ case "system":
464+ // System messages are handled separately via extractSystemPromptFromOpenAI
465+ continue
466+
467+ case "user":
468+ userMsg, toolResults := buildUserMessageFromOpenAI(msg, modelID, origin)
469+ // Merge any pending tool results from preceding "tool" role messages
470+ toolResults = append(pendingToolResults, toolResults...)
471+ pendingToolResults = nil // Reset pending tool results
472+
473+ if isLastMessage {
474+ currentUserMsg = &userMsg
475+ currentToolResults = toolResults
476+ } else {
477+ // CRITICAL: Kiro API requires content to be non-empty for history messages
478+ if strings.TrimSpace(userMsg.Content) == "" {
479+ if len(toolResults) > 0 {
480+ userMsg.Content = "Tool results provided."
481+ } else {
482+ userMsg.Content = "Continue"
483+ }
484+ }
485+ // For history messages, embed tool results in context
486+ if len(toolResults) > 0 {
487+ userMsg.UserInputMessageContext = &KiroUserInputMessageContext{
488+ ToolResults: toolResults,
489+ }
490+ }
491+ history = append(history, KiroHistoryMessage{
492+ UserInputMessage: &userMsg,
493+ })
494+ }
495+
496+ case "assistant":
497+ assistantMsg := buildAssistantMessageFromOpenAI(msg)
498+
499+ // If there are pending tool results, we need to insert a synthetic user message
500+ // before this assistant message to maintain proper conversation structure
501+ if len(pendingToolResults) > 0 {
502+ syntheticUserMsg := KiroUserInputMessage{
503+ Content: "Tool results provided.",
504+ ModelID: modelID,
505+ Origin: origin,
506+ UserInputMessageContext: &KiroUserInputMessageContext{
507+ ToolResults: pendingToolResults,
508+ },
509+ }
510+ history = append(history, KiroHistoryMessage{
511+ UserInputMessage: &syntheticUserMsg,
512+ })
513+ pendingToolResults = nil
514+ }
515+
516+ if isLastMessage {
517+ history = append(history, KiroHistoryMessage{
518+ AssistantResponseMessage: &assistantMsg,
519+ })
520+ // Create a "Continue" user message as currentMessage
521+ currentUserMsg = &KiroUserInputMessage{
522+ Content: "Continue",
523+ ModelID: modelID,
524+ Origin: origin,
525+ }
526+ } else {
527+ history = append(history, KiroHistoryMessage{
528+ AssistantResponseMessage: &assistantMsg,
529+ })
530+ }
531+
532+ case "tool":
533+ // Tool messages in OpenAI format provide results for tool_calls
534+ // These are typically followed by user or assistant messages
535+ // Collect them as pending and attach to the next user message
536+ toolCallID := msg.Get("tool_call_id").String()
537+ content := msg.Get("content").String()
538+
539+ if toolCallID != "" {
540+ toolResult := KiroToolResult{
541+ ToolUseID: toolCallID,
542+ Content: []KiroTextContent{{Text: content}},
543+ Status: "success",
544+ }
545+ // Collect pending tool results to attach to the next user message
546+ pendingToolResults = append(pendingToolResults, toolResult)
547+ }
548+ }
549+ }
550+
551+ // Handle case where tool results are at the end with no following user message
552+ if len(pendingToolResults) > 0 {
553+ currentToolResults = append(currentToolResults, pendingToolResults...)
554+ // If there's no current user message, create a synthetic one for the tool results
555+ if currentUserMsg == nil {
556+ currentUserMsg = &KiroUserInputMessage{
557+ Content: "Tool results provided.",
558+ ModelID: modelID,
559+ Origin: origin,
560+ }
561+ }
562+ }
563+
564+ return history, currentUserMsg, currentToolResults
565+}
566+
567+// buildUserMessageFromOpenAI builds a user message from OpenAI format and extracts tool results
568+func buildUserMessageFromOpenAI(msg gjson.Result, modelID, origin string) (KiroUserInputMessage, []KiroToolResult) {
569+ content := msg.Get("content")
570+ var contentBuilder strings.Builder
571+ var toolResults []KiroToolResult
572+ var images []KiroImage
573+
574+ if content.IsArray() {
575+ for _, part := range content.Array() {
576+ partType := part.Get("type").String()
577+ switch partType {
578+ case "text":
579+ contentBuilder.WriteString(part.Get("text").String())
580+ case "image_url":
581+ imageURL := part.Get("image_url.url").String()
582+ if strings.HasPrefix(imageURL, "data:") {
583+ // Parse data URL: data:image/png;base64,xxxxx
584+ if idx := strings.Index(imageURL, ";base64,"); idx != -1 {
585+ mediaType := imageURL[5:idx] // Skip "data:"
586+ data := imageURL[idx+8:] // Skip ";base64,"
587+
588+ format := ""
589+ if lastSlash := strings.LastIndex(mediaType, "/"); lastSlash != -1 {
590+ format = mediaType[lastSlash+1:]
591+ }
592+
593+ if format != "" && data != "" {
594+ images = append(images, KiroImage{
595+ Format: format,
596+ Source: KiroImageSource{
597+ Bytes: data,
598+ },
599+ })
600+ }
601+ }
602+ }
603+ }
604+ }
605+ } else if content.Type == gjson.String {
606+ contentBuilder.WriteString(content.String())
607+ }
608+
609+ userMsg := KiroUserInputMessage{
610+ Content: contentBuilder.String(),
611+ ModelID: modelID,
612+ Origin: origin,
613+ }
614+
615+ if len(images) > 0 {
616+ userMsg.Images = images
617+ }
618+
619+ return userMsg, toolResults
620+}
621+
622+// buildAssistantMessageFromOpenAI builds an assistant message from OpenAI format
623+func buildAssistantMessageFromOpenAI(msg gjson.Result) KiroAssistantResponseMessage {
624+ content := msg.Get("content")
625+ var contentBuilder strings.Builder
626+ var toolUses []KiroToolUse
627+
628+ // Handle content
629+ if content.Type == gjson.String {
630+ contentBuilder.WriteString(content.String())
631+ } else if content.IsArray() {
632+ for _, part := range content.Array() {
633+ if part.Get("type").String() == "text" {
634+ contentBuilder.WriteString(part.Get("text").String())
635+ }
636+ }
637+ }
638+
639+ // Handle tool_calls
640+ toolCalls := msg.Get("tool_calls")
641+ if toolCalls.IsArray() {
642+ for _, tc := range toolCalls.Array() {
643+ if tc.Get("type").String() != "function" {
644+ continue
645+ }
646+
647+ toolUseID := tc.Get("id").String()
648+ toolName := tc.Get("function.name").String()
649+ toolArgs := tc.Get("function.arguments").String()
650+
651+ var inputMap map[string]interface{}
652+ if err := json.Unmarshal([]byte(toolArgs), &inputMap); err != nil {
653+ log.Debugf("kiro-openai: failed to parse tool arguments: %v", err)
654+ inputMap = make(map[string]interface{})
655+ }
656+
657+ toolUses = append(toolUses, KiroToolUse{
658+ ToolUseID: toolUseID,
659+ Name: toolName,
660+ Input: inputMap,
661+ })
662+ }
663+ }
664+
665+ return KiroAssistantResponseMessage{
666+ Content: contentBuilder.String(),
667+ ToolUses: toolUses,
668+ }
669+}
670+
671+// buildFinalContent builds the final content with system prompt
672+func buildFinalContent(content, systemPrompt string, toolResults []KiroToolResult) string {
673+ var contentBuilder strings.Builder
674+
675+ if systemPrompt != "" {
676+ contentBuilder.WriteString("--- SYSTEM PROMPT ---\n")
677+ contentBuilder.WriteString(systemPrompt)
678+ contentBuilder.WriteString("\n--- END SYSTEM PROMPT ---\n\n")
679+ }
680+
681+ contentBuilder.WriteString(content)
682+ finalContent := contentBuilder.String()
683+
684+ // CRITICAL: Kiro API requires content to be non-empty
685+ if strings.TrimSpace(finalContent) == "" {
686+ if len(toolResults) > 0 {
687+ finalContent = "Tool results provided."
688+ } else {
689+ finalContent = "Continue"
690+ }
691+ log.Debugf("kiro-openai: content was empty, using default: %s", finalContent)
692+ }
693+
694+ return finalContent
695+}
696+
697+// checkThinkingModeFromOpenAI checks if thinking mode is enabled in the OpenAI request.
698+// Returns thinkingEnabled.
699+// Supports:
700+// - reasoning_effort parameter (low/medium/high/auto)
701+// - Model name containing "thinking" or "reason"
702+// - <thinking_mode> tag in system prompt (AMP/Cursor format)
703+func checkThinkingModeFromOpenAI(openaiBody []byte) bool {
704+ return checkThinkingModeFromOpenAIWithHeaders(openaiBody, nil)
705+}
706+
707+// checkThinkingModeFromOpenAIWithHeaders checks if thinking mode is enabled in the OpenAI request.
708+// Returns thinkingEnabled.
709+// Supports:
710+// - Anthropic-Beta header with interleaved-thinking (Claude CLI)
711+// - reasoning_effort parameter (low/medium/high/auto)
712+// - Model name containing "thinking" or "reason"
713+// - <thinking_mode> tag in system prompt (AMP/Cursor format)
714+func checkThinkingModeFromOpenAIWithHeaders(openaiBody []byte, headers http.Header) bool {
715+ // Check Anthropic-Beta header first (Claude CLI uses this)
716+ if kiroclaude.IsThinkingEnabledFromHeader(headers) {
717+ log.Debugf("kiro-openai: thinking mode enabled via Anthropic-Beta header")
718+ return true
719+ }
720+
721+ // Check OpenAI format: reasoning_effort parameter
722+ // Valid values: "low", "medium", "high", "auto" (not "none")
723+ reasoningEffort := gjson.GetBytes(openaiBody, "reasoning_effort")
724+ if reasoningEffort.Exists() {
725+ effort := reasoningEffort.String()
726+ if effort != "" && effort != "none" {
727+ log.Debugf("kiro-openai: thinking mode enabled via reasoning_effort: %s", effort)
728+ return true
729+ }
730+ }
731+
732+ // Check AMP/Cursor format: <thinking_mode>interleaved</thinking_mode> in system prompt
733+ bodyStr := string(openaiBody)
734+ if strings.Contains(bodyStr, "<thinking_mode>") && strings.Contains(bodyStr, "</thinking_mode>") {
735+ startTag := "<thinking_mode>"
736+ endTag := "</thinking_mode>"
737+ startIdx := strings.Index(bodyStr, startTag)
738+ if startIdx >= 0 {
739+ startIdx += len(startTag)
740+ endIdx := strings.Index(bodyStr[startIdx:], endTag)
741+ if endIdx >= 0 {
742+ thinkingMode := bodyStr[startIdx : startIdx+endIdx]
743+ if thinkingMode == "interleaved" || thinkingMode == "enabled" {
744+ log.Debugf("kiro-openai: thinking mode enabled via AMP/Cursor format: %s", thinkingMode)
745+ return true
746+ }
747+ }
748+ }
749+ }
750+
751+ // Check model name for thinking hints
752+ model := gjson.GetBytes(openaiBody, "model").String()
753+ modelLower := strings.ToLower(model)
754+ if strings.Contains(modelLower, "thinking") || strings.Contains(modelLower, "-reason") {
755+ log.Debugf("kiro-openai: thinking mode enabled via model name hint: %s", model)
756+ return true
757+ }
758+
759+ log.Debugf("kiro-openai: no thinking mode detected in OpenAI request")
760+ return false
761+}
762+
763+// hasThinkingTagInBody checks if the request body already contains thinking configuration tags.
764+// This is used to prevent duplicate injection when client (e.g., AMP/Cursor) already includes thinking config.
765+func hasThinkingTagInBody(body []byte) bool {
766+ bodyStr := string(body)
767+ return strings.Contains(bodyStr, "<thinking_mode>") || strings.Contains(bodyStr, "<max_thinking_length>")
768+}
769+
770+
771+// extractToolChoiceHint extracts tool_choice from OpenAI request and returns a system prompt hint.
772+// OpenAI tool_choice values:
773+// - "none": Don't use any tools
774+// - "auto": Model decides (default, no hint needed)
775+// - "required": Must use at least one tool
776+// - {"type":"function","function":{"name":"..."}} : Must use specific tool
777+func extractToolChoiceHint(openaiBody []byte) string {
778+ toolChoice := gjson.GetBytes(openaiBody, "tool_choice")
779+ if !toolChoice.Exists() {
780+ return ""
781+ }
782+
783+ // Handle string values
784+ if toolChoice.Type == gjson.String {
785+ switch toolChoice.String() {
786+ case "none":
787+ // Note: When tool_choice is "none", we should ideally not pass tools at all
788+ // But since we can't modify tool passing here, we add a strong hint
789+ return "[INSTRUCTION: Do NOT use any tools. Respond with text only.]"
790+ case "required":
791+ return "[INSTRUCTION: You MUST use at least one of the available tools to respond. Do not respond with text only - always make a tool call.]"
792+ case "auto":
793+ // Default behavior, no hint needed
794+ return ""
795+ }
796+ }
797+
798+ // Handle object value: {"type":"function","function":{"name":"..."}}
799+ if toolChoice.IsObject() {
800+ if toolChoice.Get("type").String() == "function" {
801+ toolName := toolChoice.Get("function.name").String()
802+ if toolName != "" {
803+ return fmt.Sprintf("[INSTRUCTION: You MUST use the tool named '%s' to respond. Do not use any other tool or respond with text only.]", toolName)
804+ }
805+ }
806+ }
807+
808+ return ""
809+}
810+
811+// extractResponseFormatHint extracts response_format from OpenAI request and returns a system prompt hint.
812+// OpenAI response_format values:
813+// - {"type": "text"}: Default, no hint needed
814+// - {"type": "json_object"}: Must respond with valid JSON
815+// - {"type": "json_schema", "json_schema": {...}}: Must respond with JSON matching schema
816+func extractResponseFormatHint(openaiBody []byte) string {
817+ responseFormat := gjson.GetBytes(openaiBody, "response_format")
818+ if !responseFormat.Exists() {
819+ return ""
820+ }
821+
822+ formatType := responseFormat.Get("type").String()
823+ switch formatType {
824+ case "json_object":
825+ return "[INSTRUCTION: You MUST respond with valid JSON only. Do not include any text before or after the JSON. Do not wrap the JSON in markdown code blocks. Output raw JSON directly.]"
826+ case "json_schema":
827+ // Extract schema if provided
828+ schema := responseFormat.Get("json_schema.schema")
829+ if schema.Exists() {
830+ schemaStr := schema.Raw
831+ // Truncate if too long
832+ if len(schemaStr) > 500 {
833+ schemaStr = schemaStr[:500] + "..."
834+ }
835+ return fmt.Sprintf("[INSTRUCTION: You MUST respond with valid JSON that matches this schema: %s. Do not include any text before or after the JSON. Do not wrap the JSON in markdown code blocks. Output raw JSON directly.]", schemaStr)
836+ }
837+ return "[INSTRUCTION: You MUST respond with valid JSON only. Do not include any text before or after the JSON. Do not wrap the JSON in markdown code blocks. Output raw JSON directly.]"
838+ case "text":
839+ // Default behavior, no hint needed
840+ return ""
841+ }
842+
843+ return ""
844+}
845+
846+// deduplicateToolResults removes duplicate tool results
847+func deduplicateToolResults(toolResults []KiroToolResult) []KiroToolResult {
848+ if len(toolResults) == 0 {
849+ return toolResults
850+ }
851+
852+ seenIDs := make(map[string]bool)
853+ unique := make([]KiroToolResult, 0, len(toolResults))
854+ for _, tr := range toolResults {
855+ if !seenIDs[tr.ToolUseID] {
856+ seenIDs[tr.ToolUseID] = true
857+ unique = append(unique, tr)
858+ } else {
859+ log.Debugf("kiro-openai: skipping duplicate toolResult: %s", tr.ToolUseID)
860+ }
861+ }
862+ return unique
863+}
internal/translator/kiro/openai/kiro_openai_request_test.go+386−0
@@ -0,0 +1,386 @@
1+package openai
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+// TestToolResultsAttachedToCurrentMessage verifies that tool results from "tool" role messages
9+// are properly attached to the current user message (the last message in the conversation).
10+// This is critical for LiteLLM-translated requests where tool results appear as separate messages.
11+func TestToolResultsAttachedToCurrentMessage(t *testing.T) {
12+ // OpenAI format request simulating LiteLLM's translation from Anthropic format
13+ // Sequence: user -> assistant (with tool_calls) -> tool (result) -> user
14+ // The last user message should have the tool results attached
15+ input := []byte(`{
16+ "model": "kiro-claude-opus-4-5-agentic",
17+ "messages": [
18+ {"role": "user", "content": "Hello, can you read a file for me?"},
19+ {
20+ "role": "assistant",
21+ "content": "I'll read that file for you.",
22+ "tool_calls": [
23+ {
24+ "id": "call_abc123",
25+ "type": "function",
26+ "function": {
27+ "name": "Read",
28+ "arguments": "{\"file_path\": \"/tmp/test.txt\"}"
29+ }
30+ }
31+ ]
32+ },
33+ {
34+ "role": "tool",
35+ "tool_call_id": "call_abc123",
36+ "content": "File contents: Hello World!"
37+ },
38+ {"role": "user", "content": "What did the file say?"}
39+ ]
40+ }`)
41+
42+ result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
43+
44+ var payload KiroPayload
45+ if err := json.Unmarshal(result, &payload); err != nil {
46+ t.Fatalf("Failed to unmarshal result: %v", err)
47+ }
48+
49+ // The last user message becomes currentMessage
50+ // History should have: user (first), assistant (with tool_calls)
51+ t.Logf("History count: %d", len(payload.ConversationState.History))
52+ if len(payload.ConversationState.History) != 2 {
53+ t.Errorf("Expected 2 history entries (user + assistant), got %d", len(payload.ConversationState.History))
54+ }
55+
56+ // Tool results should be attached to currentMessage (the last user message)
57+ ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext
58+ if ctx == nil {
59+ t.Fatal("Expected currentMessage to have UserInputMessageContext with tool results")
60+ }
61+
62+ if len(ctx.ToolResults) != 1 {
63+ t.Fatalf("Expected 1 tool result in currentMessage, got %d", len(ctx.ToolResults))
64+ }
65+
66+ tr := ctx.ToolResults[0]
67+ if tr.ToolUseID != "call_abc123" {
68+ t.Errorf("Expected toolUseId 'call_abc123', got '%s'", tr.ToolUseID)
69+ }
70+ if len(tr.Content) == 0 || tr.Content[0].Text != "File contents: Hello World!" {
71+ t.Errorf("Tool result content mismatch, got: %+v", tr.Content)
72+ }
73+}
74+
75+// TestToolResultsInHistoryUserMessage verifies that when there are multiple user messages
76+// after tool results, the tool results are attached to the correct user message in history.
77+func TestToolResultsInHistoryUserMessage(t *testing.T) {
78+ // Sequence: user -> assistant (with tool_calls) -> tool (result) -> user -> assistant -> user
79+ // The first user after tool should have tool results in history
80+ input := []byte(`{
81+ "model": "kiro-claude-opus-4-5-agentic",
82+ "messages": [
83+ {"role": "user", "content": "Hello"},
84+ {
85+ "role": "assistant",
86+ "content": "I'll read the file.",
87+ "tool_calls": [
88+ {
89+ "id": "call_1",
90+ "type": "function",
91+ "function": {
92+ "name": "Read",
93+ "arguments": "{}"
94+ }
95+ }
96+ ]
97+ },
98+ {
99+ "role": "tool",
100+ "tool_call_id": "call_1",
101+ "content": "File result"
102+ },
103+ {"role": "user", "content": "Thanks for the file"},
104+ {"role": "assistant", "content": "You're welcome"},
105+ {"role": "user", "content": "Bye"}
106+ ]
107+ }`)
108+
109+ result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
110+
111+ var payload KiroPayload
112+ if err := json.Unmarshal(result, &payload); err != nil {
113+ t.Fatalf("Failed to unmarshal result: %v", err)
114+ }
115+
116+ // History should have: user, assistant, user (with tool results), assistant
117+ // CurrentMessage should be: last user "Bye"
118+ t.Logf("History count: %d", len(payload.ConversationState.History))
119+
120+ // Find the user message in history with tool results
121+ foundToolResults := false
122+ for i, h := range payload.ConversationState.History {
123+ if h.UserInputMessage != nil {
124+ t.Logf("History[%d]: user message content=%q", i, h.UserInputMessage.Content)
125+ if h.UserInputMessage.UserInputMessageContext != nil {
126+ if len(h.UserInputMessage.UserInputMessageContext.ToolResults) > 0 {
127+ foundToolResults = true
128+ t.Logf(" Found %d tool results", len(h.UserInputMessage.UserInputMessageContext.ToolResults))
129+ tr := h.UserInputMessage.UserInputMessageContext.ToolResults[0]
130+ if tr.ToolUseID != "call_1" {
131+ t.Errorf("Expected toolUseId 'call_1', got '%s'", tr.ToolUseID)
132+ }
133+ }
134+ }
135+ }
136+ if h.AssistantResponseMessage != nil {
137+ t.Logf("History[%d]: assistant message content=%q", i, h.AssistantResponseMessage.Content)
138+ }
139+ }
140+
141+ if !foundToolResults {
142+ t.Error("Tool results were not attached to any user message in history")
143+ }
144+}
145+
146+// TestToolResultsWithMultipleToolCalls verifies handling of multiple tool calls
147+func TestToolResultsWithMultipleToolCalls(t *testing.T) {
148+ input := []byte(`{
149+ "model": "kiro-claude-opus-4-5-agentic",
150+ "messages": [
151+ {"role": "user", "content": "Read two files for me"},
152+ {
153+ "role": "assistant",
154+ "content": "I'll read both files.",
155+ "tool_calls": [
156+ {
157+ "id": "call_1",
158+ "type": "function",
159+ "function": {
160+ "name": "Read",
161+ "arguments": "{\"file_path\": \"/tmp/file1.txt\"}"
162+ }
163+ },
164+ {
165+ "id": "call_2",
166+ "type": "function",
167+ "function": {
168+ "name": "Read",
169+ "arguments": "{\"file_path\": \"/tmp/file2.txt\"}"
170+ }
171+ }
172+ ]
173+ },
174+ {
175+ "role": "tool",
176+ "tool_call_id": "call_1",
177+ "content": "Content of file 1"
178+ },
179+ {
180+ "role": "tool",
181+ "tool_call_id": "call_2",
182+ "content": "Content of file 2"
183+ },
184+ {"role": "user", "content": "What do they say?"}
185+ ]
186+ }`)
187+
188+ result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
189+
190+ var payload KiroPayload
191+ if err := json.Unmarshal(result, &payload); err != nil {
192+ t.Fatalf("Failed to unmarshal result: %v", err)
193+ }
194+
195+ t.Logf("History count: %d", len(payload.ConversationState.History))
196+ t.Logf("CurrentMessage content: %q", payload.ConversationState.CurrentMessage.UserInputMessage.Content)
197+
198+ // Check if there are any tool results anywhere
199+ var totalToolResults int
200+ for i, h := range payload.ConversationState.History {
201+ if h.UserInputMessage != nil && h.UserInputMessage.UserInputMessageContext != nil {
202+ count := len(h.UserInputMessage.UserInputMessageContext.ToolResults)
203+ t.Logf("History[%d] user message has %d tool results", i, count)
204+ totalToolResults += count
205+ }
206+ }
207+
208+ ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext
209+ if ctx != nil {
210+ t.Logf("CurrentMessage has %d tool results", len(ctx.ToolResults))
211+ totalToolResults += len(ctx.ToolResults)
212+ } else {
213+ t.Logf("CurrentMessage has no UserInputMessageContext")
214+ }
215+
216+ if totalToolResults != 2 {
217+ t.Errorf("Expected 2 tool results total, got %d", totalToolResults)
218+ }
219+}
220+
221+// TestToolResultsAtEndOfConversation verifies tool results are handled when
222+// the conversation ends with tool results (no following user message)
223+func TestToolResultsAtEndOfConversation(t *testing.T) {
224+ input := []byte(`{
225+ "model": "kiro-claude-opus-4-5-agentic",
226+ "messages": [
227+ {"role": "user", "content": "Read a file"},
228+ {
229+ "role": "assistant",
230+ "content": "Reading the file.",
231+ "tool_calls": [
232+ {
233+ "id": "call_end",
234+ "type": "function",
235+ "function": {
236+ "name": "Read",
237+ "arguments": "{\"file_path\": \"/tmp/test.txt\"}"
238+ }
239+ }
240+ ]
241+ },
242+ {
243+ "role": "tool",
244+ "tool_call_id": "call_end",
245+ "content": "File contents here"
246+ }
247+ ]
248+ }`)
249+
250+ result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
251+
252+ var payload KiroPayload
253+ if err := json.Unmarshal(result, &payload); err != nil {
254+ t.Fatalf("Failed to unmarshal result: %v", err)
255+ }
256+
257+ // When the last message is a tool result, a synthetic user message is created
258+ // and tool results should be attached to it
259+ ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext
260+ if ctx == nil || len(ctx.ToolResults) == 0 {
261+ t.Error("Expected tool results to be attached to current message when conversation ends with tool result")
262+ } else {
263+ if ctx.ToolResults[0].ToolUseID != "call_end" {
264+ t.Errorf("Expected toolUseId 'call_end', got '%s'", ctx.ToolResults[0].ToolUseID)
265+ }
266+ }
267+}
268+
269+// TestToolResultsFollowedByAssistant verifies handling when tool results are followed
270+// by an assistant message (no intermediate user message).
271+// This is the pattern from LiteLLM translation of Anthropic format where:
272+// user message has ONLY tool_result blocks -> LiteLLM creates tool messages
273+// then the next message is assistant
274+func TestToolResultsFollowedByAssistant(t *testing.T) {
275+ // Sequence: user -> assistant (with tool_calls) -> tool -> tool -> assistant -> user
276+ // This simulates LiteLLM's translation of:
277+ // user: "Read files"
278+ // assistant: [tool_use, tool_use]
279+ // user: [tool_result, tool_result] <- becomes multiple "tool" role messages
280+ // assistant: "I've read them"
281+ // user: "What did they say?"
282+ input := []byte(`{
283+ "model": "kiro-claude-opus-4-5-agentic",
284+ "messages": [
285+ {"role": "user", "content": "Read two files for me"},
286+ {
287+ "role": "assistant",
288+ "content": "I'll read both files.",
289+ "tool_calls": [
290+ {
291+ "id": "call_1",
292+ "type": "function",
293+ "function": {
294+ "name": "Read",
295+ "arguments": "{\"file_path\": \"/tmp/a.txt\"}"
296+ }
297+ },
298+ {
299+ "id": "call_2",
300+ "type": "function",
301+ "function": {
302+ "name": "Read",
303+ "arguments": "{\"file_path\": \"/tmp/b.txt\"}"
304+ }
305+ }
306+ ]
307+ },
308+ {
309+ "role": "tool",
310+ "tool_call_id": "call_1",
311+ "content": "Contents of file A"
312+ },
313+ {
314+ "role": "tool",
315+ "tool_call_id": "call_2",
316+ "content": "Contents of file B"
317+ },
318+ {
319+ "role": "assistant",
320+ "content": "I've read both files."
321+ },
322+ {"role": "user", "content": "What did they say?"}
323+ ]
324+ }`)
325+
326+ result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
327+
328+ var payload KiroPayload
329+ if err := json.Unmarshal(result, &payload); err != nil {
330+ t.Fatalf("Failed to unmarshal result: %v", err)
331+ }
332+
333+ t.Logf("History count: %d", len(payload.ConversationState.History))
334+
335+ // Tool results should be attached to a synthetic user message or the history should be valid
336+ var totalToolResults int
337+ for i, h := range payload.ConversationState.History {
338+ if h.UserInputMessage != nil {
339+ t.Logf("History[%d]: user message content=%q", i, h.UserInputMessage.Content)
340+ if h.UserInputMessage.UserInputMessageContext != nil {
341+ count := len(h.UserInputMessage.UserInputMessageContext.ToolResults)
342+ t.Logf(" Has %d tool results", count)
343+ totalToolResults += count
344+ }
345+ }
346+ if h.AssistantResponseMessage != nil {
347+ t.Logf("History[%d]: assistant message content=%q", i, h.AssistantResponseMessage.Content)
348+ }
349+ }
350+
351+ ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext
352+ if ctx != nil {
353+ t.Logf("CurrentMessage has %d tool results", len(ctx.ToolResults))
354+ totalToolResults += len(ctx.ToolResults)
355+ }
356+
357+ if totalToolResults != 2 {
358+ t.Errorf("Expected 2 tool results total, got %d", totalToolResults)
359+ }
360+}
361+
362+// TestAssistantEndsConversation verifies handling when assistant is the last message
363+func TestAssistantEndsConversation(t *testing.T) {
364+ input := []byte(`{
365+ "model": "kiro-claude-opus-4-5-agentic",
366+ "messages": [
367+ {"role": "user", "content": "Hello"},
368+ {
369+ "role": "assistant",
370+ "content": "Hi there!"
371+ }
372+ ]
373+ }`)
374+
375+ result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
376+
377+ var payload KiroPayload
378+ if err := json.Unmarshal(result, &payload); err != nil {
379+ t.Fatalf("Failed to unmarshal result: %v", err)
380+ }
381+
382+ // When assistant is last, a "Continue" user message should be created
383+ if payload.ConversationState.CurrentMessage.UserInputMessage.Content == "" {
384+ t.Error("Expected a 'Continue' message to be created when assistant is last")
385+ }
386+}
internal/translator/kiro/openai/kiro_openai_response.go+277−0
@@ -0,0 +1,277 @@
1+// Package openai provides response translation from Kiro to OpenAI format.
2+// This package handles the conversion of Kiro API responses into OpenAI Chat Completions-compatible
3+// JSON format, transforming streaming events and non-streaming responses.
4+package openai
5+
6+import (
7+ "encoding/json"
8+ "fmt"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/google/uuid"
13+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
14+ log "github.com/sirupsen/logrus"
15+)
16+
17+// functionCallIDCounter provides a process-wide unique counter for function call identifiers.
18+var functionCallIDCounter uint64
19+
20+// BuildOpenAIResponse constructs an OpenAI Chat Completions-compatible response.
21+// Supports tool_calls when tools are present in the response.
22+// stopReason is passed from upstream; fallback logic applied if empty.
23+func BuildOpenAIResponse(content string, toolUses []KiroToolUse, model string, usageInfo usage.Detail, stopReason string) []byte {
24+ return BuildOpenAIResponseWithReasoning(content, "", toolUses, model, usageInfo, stopReason)
25+}
26+
27+// BuildOpenAIResponseWithReasoning constructs an OpenAI Chat Completions-compatible response with reasoning_content support.
28+// Supports tool_calls when tools are present in the response.
29+// reasoningContent is included as reasoning_content field in the message when present.
30+// stopReason is passed from upstream; fallback logic applied if empty.
31+func BuildOpenAIResponseWithReasoning(content, reasoningContent string, toolUses []KiroToolUse, model string, usageInfo usage.Detail, stopReason string) []byte {
32+ // Build the message object
33+ message := map[string]interface{}{
34+ "role": "assistant",
35+ "content": content,
36+ }
37+
38+ // Add reasoning_content if present (for thinking/reasoning models)
39+ if reasoningContent != "" {
40+ message["reasoning_content"] = reasoningContent
41+ }
42+
43+ // Add tool_calls if present
44+ if len(toolUses) > 0 {
45+ var toolCalls []map[string]interface{}
46+ for i, tu := range toolUses {
47+ inputJSON, _ := json.Marshal(tu.Input)
48+ toolCalls = append(toolCalls, map[string]interface{}{
49+ "id": tu.ToolUseID,
50+ "type": "function",
51+ "index": i,
52+ "function": map[string]interface{}{
53+ "name": tu.Name,
54+ "arguments": string(inputJSON),
55+ },
56+ })
57+ }
58+ message["tool_calls"] = toolCalls
59+ // When tool_calls are present, content should be null according to OpenAI spec
60+ if content == "" {
61+ message["content"] = nil
62+ }
63+ }
64+
65+ // Use upstream stopReason; apply fallback logic if not provided
66+ finishReason := mapKiroStopReasonToOpenAI(stopReason)
67+ if finishReason == "" {
68+ finishReason = "stop"
69+ if len(toolUses) > 0 {
70+ finishReason = "tool_calls"
71+ }
72+ log.Debugf("kiro-openai: buildOpenAIResponse using fallback finish_reason: %s", finishReason)
73+ }
74+
75+ response := map[string]interface{}{
76+ "id": "chatcmpl-" + uuid.New().String()[:24],
77+ "object": "chat.completion",
78+ "created": time.Now().Unix(),
79+ "model": model,
80+ "choices": []map[string]interface{}{
81+ {
82+ "index": 0,
83+ "message": message,
84+ "finish_reason": finishReason,
85+ },
86+ },
87+ "usage": map[string]interface{}{
88+ "prompt_tokens": usageInfo.InputTokens,
89+ "completion_tokens": usageInfo.OutputTokens,
90+ "total_tokens": usageInfo.InputTokens + usageInfo.OutputTokens,
91+ },
92+ }
93+
94+ result, _ := json.Marshal(response)
95+ return result
96+}
97+
98+// mapKiroStopReasonToOpenAI converts Kiro/Claude stop_reason to OpenAI finish_reason
99+func mapKiroStopReasonToOpenAI(stopReason string) string {
100+ switch stopReason {
101+ case "end_turn":
102+ return "stop"
103+ case "stop_sequence":
104+ return "stop"
105+ case "tool_use":
106+ return "tool_calls"
107+ case "max_tokens":
108+ return "length"
109+ case "content_filtered":
110+ return "content_filter"
111+ default:
112+ return stopReason
113+ }
114+}
115+
116+// BuildOpenAIStreamChunk constructs an OpenAI Chat Completions streaming chunk.
117+// This is the delta format used in streaming responses.
118+func BuildOpenAIStreamChunk(model string, deltaContent string, deltaToolCalls []map[string]interface{}, finishReason string, index int) []byte {
119+ delta := map[string]interface{}{}
120+
121+ // First chunk should include role
122+ if index == 0 && deltaContent == "" && len(deltaToolCalls) == 0 {
123+ delta["role"] = "assistant"
124+ delta["content"] = ""
125+ } else if deltaContent != "" {
126+ delta["content"] = deltaContent
127+ }
128+
129+ // Add tool_calls delta if present
130+ if len(deltaToolCalls) > 0 {
131+ delta["tool_calls"] = deltaToolCalls
132+ }
133+
134+ choice := map[string]interface{}{
135+ "index": 0,
136+ "delta": delta,
137+ }
138+
139+ if finishReason != "" {
140+ choice["finish_reason"] = finishReason
141+ } else {
142+ choice["finish_reason"] = nil
143+ }
144+
145+ chunk := map[string]interface{}{
146+ "id": "chatcmpl-" + uuid.New().String()[:12],
147+ "object": "chat.completion.chunk",
148+ "created": time.Now().Unix(),
149+ "model": model,
150+ "choices": []map[string]interface{}{choice},
151+ }
152+
153+ result, _ := json.Marshal(chunk)
154+ return result
155+}
156+
157+// BuildOpenAIStreamChunkWithToolCallStart creates a stream chunk for tool call start
158+func BuildOpenAIStreamChunkWithToolCallStart(model string, toolUseID, toolName string, toolIndex int) []byte {
159+ toolCall := map[string]interface{}{
160+ "index": toolIndex,
161+ "id": toolUseID,
162+ "type": "function",
163+ "function": map[string]interface{}{
164+ "name": toolName,
165+ "arguments": "",
166+ },
167+ }
168+
169+ delta := map[string]interface{}{
170+ "tool_calls": []map[string]interface{}{toolCall},
171+ }
172+
173+ choice := map[string]interface{}{
174+ "index": 0,
175+ "delta": delta,
176+ "finish_reason": nil,
177+ }
178+
179+ chunk := map[string]interface{}{
180+ "id": "chatcmpl-" + uuid.New().String()[:12],
181+ "object": "chat.completion.chunk",
182+ "created": time.Now().Unix(),
183+ "model": model,
184+ "choices": []map[string]interface{}{choice},
185+ }
186+
187+ result, _ := json.Marshal(chunk)
188+ return result
189+}
190+
191+// BuildOpenAIStreamChunkWithToolCallDelta creates a stream chunk for tool call arguments delta
192+func BuildOpenAIStreamChunkWithToolCallDelta(model string, argumentsDelta string, toolIndex int) []byte {
193+ toolCall := map[string]interface{}{
194+ "index": toolIndex,
195+ "function": map[string]interface{}{
196+ "arguments": argumentsDelta,
197+ },
198+ }
199+
200+ delta := map[string]interface{}{
201+ "tool_calls": []map[string]interface{}{toolCall},
202+ }
203+
204+ choice := map[string]interface{}{
205+ "index": 0,
206+ "delta": delta,
207+ "finish_reason": nil,
208+ }
209+
210+ chunk := map[string]interface{}{
211+ "id": "chatcmpl-" + uuid.New().String()[:12],
212+ "object": "chat.completion.chunk",
213+ "created": time.Now().Unix(),
214+ "model": model,
215+ "choices": []map[string]interface{}{choice},
216+ }
217+
218+ result, _ := json.Marshal(chunk)
219+ return result
220+}
221+
222+// BuildOpenAIStreamDoneChunk creates the final [DONE] stream event
223+func BuildOpenAIStreamDoneChunk() []byte {
224+ return []byte("data: [DONE]")
225+}
226+
227+// BuildOpenAIStreamFinishChunk creates the final chunk with finish_reason
228+func BuildOpenAIStreamFinishChunk(model string, finishReason string) []byte {
229+ choice := map[string]interface{}{
230+ "index": 0,
231+ "delta": map[string]interface{}{},
232+ "finish_reason": finishReason,
233+ }
234+
235+ chunk := map[string]interface{}{
236+ "id": "chatcmpl-" + uuid.New().String()[:12],
237+ "object": "chat.completion.chunk",
238+ "created": time.Now().Unix(),
239+ "model": model,
240+ "choices": []map[string]interface{}{choice},
241+ }
242+
243+ result, _ := json.Marshal(chunk)
244+ return result
245+}
246+
247+// BuildOpenAIStreamUsageChunk creates a chunk with usage information (optional, for stream_options.include_usage)
248+func BuildOpenAIStreamUsageChunk(model string, usageInfo usage.Detail) []byte {
249+ chunk := map[string]interface{}{
250+ "id": "chatcmpl-" + uuid.New().String()[:12],
251+ "object": "chat.completion.chunk",
252+ "created": time.Now().Unix(),
253+ "model": model,
254+ "choices": []map[string]interface{}{},
255+ "usage": map[string]interface{}{
256+ "prompt_tokens": usageInfo.InputTokens,
257+ "completion_tokens": usageInfo.OutputTokens,
258+ "total_tokens": usageInfo.InputTokens + usageInfo.OutputTokens,
259+ },
260+ }
261+
262+ result, _ := json.Marshal(chunk)
263+ return result
264+}
265+
266+// GenerateToolCallID generates a unique tool call ID in OpenAI format
267+func GenerateToolCallID(toolName string) string {
268+ return fmt.Sprintf("call_%s_%d_%d", toolName[:min(8, len(toolName))], time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))
269+}
270+
271+// min returns the minimum of two integers
272+func min(a, b int) int {
273+ if a < b {
274+ return a
275+ }
276+ return b
277+}
internal/translator/kiro/openai/kiro_openai_stream.go+212−0
@@ -0,0 +1,212 @@
1+// Package openai provides streaming SSE event building for OpenAI format.
2+// This package handles the construction of OpenAI-compatible Server-Sent Events (SSE)
3+// for streaming responses from Kiro API.
4+package openai
5+
6+import (
7+ "encoding/json"
8+ "time"
9+
10+ "github.com/google/uuid"
11+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
12+)
13+
14+// OpenAIStreamState tracks the state of streaming response conversion
15+type OpenAIStreamState struct {
16+ ChunkIndex int
17+ ToolCallIndex int
18+ HasSentFirstChunk bool
19+ Model string
20+ ResponseID string
21+ Created int64
22+}
23+
24+// NewOpenAIStreamState creates a new stream state for tracking
25+func NewOpenAIStreamState(model string) *OpenAIStreamState {
26+ return &OpenAIStreamState{
27+ ChunkIndex: 0,
28+ ToolCallIndex: 0,
29+ HasSentFirstChunk: false,
30+ Model: model,
31+ ResponseID: "chatcmpl-" + uuid.New().String()[:24],
32+ Created: time.Now().Unix(),
33+ }
34+}
35+
36+// FormatSSEEvent formats a JSON payload for SSE streaming.
37+// Note: This returns raw JSON data without "data:" prefix.
38+// The SSE "data:" prefix is added by the Handler layer (e.g., openai_handlers.go)
39+// to maintain architectural consistency and avoid double-prefix issues.
40+func FormatSSEEvent(data []byte) string {
41+ return string(data)
42+}
43+
44+// BuildOpenAISSETextDelta creates an SSE event for text content delta
45+func BuildOpenAISSETextDelta(state *OpenAIStreamState, textDelta string) string {
46+ delta := map[string]interface{}{
47+ "content": textDelta,
48+ }
49+
50+ // Include role in first chunk
51+ if !state.HasSentFirstChunk {
52+ delta["role"] = "assistant"
53+ state.HasSentFirstChunk = true
54+ }
55+
56+ chunk := buildBaseChunk(state, delta, nil)
57+ result, _ := json.Marshal(chunk)
58+ state.ChunkIndex++
59+ return FormatSSEEvent(result)
60+}
61+
62+// BuildOpenAISSEToolCallStart creates an SSE event for tool call start
63+func BuildOpenAISSEToolCallStart(state *OpenAIStreamState, toolUseID, toolName string) string {
64+ toolCall := map[string]interface{}{
65+ "index": state.ToolCallIndex,
66+ "id": toolUseID,
67+ "type": "function",
68+ "function": map[string]interface{}{
69+ "name": toolName,
70+ "arguments": "",
71+ },
72+ }
73+
74+ delta := map[string]interface{}{
75+ "tool_calls": []map[string]interface{}{toolCall},
76+ }
77+
78+ // Include role in first chunk if not sent yet
79+ if !state.HasSentFirstChunk {
80+ delta["role"] = "assistant"
81+ state.HasSentFirstChunk = true
82+ }
83+
84+ chunk := buildBaseChunk(state, delta, nil)
85+ result, _ := json.Marshal(chunk)
86+ state.ChunkIndex++
87+ return FormatSSEEvent(result)
88+}
89+
90+// BuildOpenAISSEToolCallArgumentsDelta creates an SSE event for tool call arguments delta
91+func BuildOpenAISSEToolCallArgumentsDelta(state *OpenAIStreamState, argumentsDelta string, toolIndex int) string {
92+ toolCall := map[string]interface{}{
93+ "index": toolIndex,
94+ "function": map[string]interface{}{
95+ "arguments": argumentsDelta,
96+ },
97+ }
98+
99+ delta := map[string]interface{}{
100+ "tool_calls": []map[string]interface{}{toolCall},
101+ }
102+
103+ chunk := buildBaseChunk(state, delta, nil)
104+ result, _ := json.Marshal(chunk)
105+ state.ChunkIndex++
106+ return FormatSSEEvent(result)
107+}
108+
109+// BuildOpenAISSEFinish creates an SSE event with finish_reason
110+func BuildOpenAISSEFinish(state *OpenAIStreamState, finishReason string) string {
111+ chunk := buildBaseChunk(state, map[string]interface{}{}, &finishReason)
112+ result, _ := json.Marshal(chunk)
113+ state.ChunkIndex++
114+ return FormatSSEEvent(result)
115+}
116+
117+// BuildOpenAISSEUsage creates an SSE event with usage information
118+func BuildOpenAISSEUsage(state *OpenAIStreamState, usageInfo usage.Detail) string {
119+ chunk := map[string]interface{}{
120+ "id": state.ResponseID,
121+ "object": "chat.completion.chunk",
122+ "created": state.Created,
123+ "model": state.Model,
124+ "choices": []map[string]interface{}{},
125+ "usage": map[string]interface{}{
126+ "prompt_tokens": usageInfo.InputTokens,
127+ "completion_tokens": usageInfo.OutputTokens,
128+ "total_tokens": usageInfo.InputTokens + usageInfo.OutputTokens,
129+ },
130+ }
131+ result, _ := json.Marshal(chunk)
132+ return FormatSSEEvent(result)
133+}
134+
135+// BuildOpenAISSEDone creates the final [DONE] SSE event.
136+// Note: This returns raw "[DONE]" without "data:" prefix.
137+// The SSE "data:" prefix is added by the Handler layer (e.g., openai_handlers.go)
138+// to maintain architectural consistency and avoid double-prefix issues.
139+func BuildOpenAISSEDone() string {
140+ return "[DONE]"
141+}
142+
143+// buildBaseChunk creates a base chunk structure for streaming
144+func buildBaseChunk(state *OpenAIStreamState, delta map[string]interface{}, finishReason *string) map[string]interface{} {
145+ choice := map[string]interface{}{
146+ "index": 0,
147+ "delta": delta,
148+ }
149+
150+ if finishReason != nil {
151+ choice["finish_reason"] = *finishReason
152+ } else {
153+ choice["finish_reason"] = nil
154+ }
155+
156+ return map[string]interface{}{
157+ "id": state.ResponseID,
158+ "object": "chat.completion.chunk",
159+ "created": state.Created,
160+ "model": state.Model,
161+ "choices": []map[string]interface{}{choice},
162+ }
163+}
164+
165+// BuildOpenAISSEReasoningDelta creates an SSE event for reasoning content delta
166+// This is used for o1/o3 style models that expose reasoning tokens
167+func BuildOpenAISSEReasoningDelta(state *OpenAIStreamState, reasoningDelta string) string {
168+ delta := map[string]interface{}{
169+ "reasoning_content": reasoningDelta,
170+ }
171+
172+ // Include role in first chunk
173+ if !state.HasSentFirstChunk {
174+ delta["role"] = "assistant"
175+ state.HasSentFirstChunk = true
176+ }
177+
178+ chunk := buildBaseChunk(state, delta, nil)
179+ result, _ := json.Marshal(chunk)
180+ state.ChunkIndex++
181+ return FormatSSEEvent(result)
182+}
183+
184+// BuildOpenAISSEFirstChunk creates the first chunk with role only
185+func BuildOpenAISSEFirstChunk(state *OpenAIStreamState) string {
186+ delta := map[string]interface{}{
187+ "role": "assistant",
188+ "content": "",
189+ }
190+
191+ state.HasSentFirstChunk = true
192+ chunk := buildBaseChunk(state, delta, nil)
193+ result, _ := json.Marshal(chunk)
194+ state.ChunkIndex++
195+ return FormatSSEEvent(result)
196+}
197+
198+// ThinkingTagState tracks state for thinking tag detection in streaming
199+type ThinkingTagState struct {
200+ InThinkingBlock bool
201+ PendingStartChars int
202+ PendingEndChars int
203+}
204+
205+// NewThinkingTagState creates a new thinking tag state
206+func NewThinkingTagState() *ThinkingTagState {
207+ return &ThinkingTagState{
208+ InThinkingBlock: false,
209+ PendingStartChars: 0,
210+ PendingEndChars: 0,
211+ }
212+}
internal/watcher/events.go+67−1
@@ -13,6 +13,7 @@ import ( · 12 unmodified lines
1313 "time"
1414
1515 "github.com/fsnotify/fsnotify"
16+ kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
1617 log "github.com/sirupsen/logrus"
1718 )
1819
@@ -39,12 +40,35 @@ func (w *Watcher) start(ctx context.Context) error { · 20 unmodified lines
3940 }
4041 log.Debugf("watching auth directory: %s", w.authDir)
4142
43+ w.watchKiroIDETokenFile()
44+
4245 go w.processEvents(ctx)
4346
4447 w.reloadClients(true, nil, false)
4548 return nil
4649 }
4750
51+func (w *Watcher) watchKiroIDETokenFile() {
52+ homeDir, err := os.UserHomeDir()
53+ if err != nil {
54+ log.Debugf("failed to get home directory for Kiro IDE token watch: %v", err)
55+ return
56+ }
57+
58+ kiroTokenDir := filepath.Join(homeDir, ".aws", "sso", "cache")
59+
60+ if _, statErr := os.Stat(kiroTokenDir); os.IsNotExist(statErr) {
61+ log.Debugf("Kiro IDE token directory does not exist: %s", kiroTokenDir)
62+ return
63+ }
64+
65+ if errAdd := w.watcher.Add(kiroTokenDir); errAdd != nil {
66+ log.Debugf("failed to watch Kiro IDE token directory %s: %v", kiroTokenDir, errAdd)
67+ return
68+ }
69+ log.Debugf("watching Kiro IDE token directory: %s", kiroTokenDir)
70+}
71+
4872 func (w *Watcher) processEvents(ctx context.Context) {
4973 for {
5074 select {
@@ -73,11 +97,17 @@ func (w *Watcher) handleEvent(event fsnotify.Event) { · 22 unmodified lines
7397 isConfigEvent := normalizedName == normalizedConfigPath && event.Op&configOps != 0
7498 authOps := fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename
7599 isAuthJSON := strings.HasPrefix(normalizedName, normalizedAuthDir) && strings.HasSuffix(normalizedName, ".json") && event.Op&authOps != 0
76− if !isConfigEvent && !isAuthJSON {
100+ isKiroIDEToken := w.isKiroIDETokenFile(event.Name) && event.Op&authOps != 0
101+ if !isConfigEvent && !isAuthJSON && !isKiroIDEToken {
77102 // Ignore unrelated files (e.g., cookie snapshots *.cookie) and other noise.
78103 return
79104 }
80105
106+ if isKiroIDEToken {
107+ w.handleKiroIDETokenChange(event)
108+ return
109+ }
110+
81111 now := time.Now()
82112 log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name)
83113
@@ -124,6 +154,42 @@ func (w *Watcher) handleEvent(event fsnotify.Event) { · 40 unmodified lines
124154 }
125155 }
126156
157+func (w *Watcher) isKiroIDETokenFile(path string) bool {
158+ normalized := filepath.ToSlash(path)
159+ return strings.HasSuffix(normalized, "kiro-auth-token.json") && strings.Contains(normalized, ".aws/sso/cache")
160+}
161+
162+func (w *Watcher) handleKiroIDETokenChange(event fsnotify.Event) {
163+ log.Debugf("Kiro IDE token file event detected: %s %s", event.Op.String(), event.Name)
164+
165+ if event.Op&(fsnotify.Remove|fsnotify.Rename) != 0 {
166+ time.Sleep(replaceCheckDelay)
167+ if _, statErr := os.Stat(event.Name); statErr != nil {
168+ log.Debugf("Kiro IDE token file removed: %s", event.Name)
169+ return
170+ }
171+ }
172+
173+ tokenData, err := kiroauth.LoadKiroIDEToken()
174+ if err != nil {
175+ log.Debugf("failed to load Kiro IDE token after change: %v", err)
176+ return
177+ }
178+
179+ log.Infof("Kiro IDE token file updated, access token refreshed (provider: %s)", tokenData.Provider)
180+
181+ w.refreshAuthState(true)
182+
183+ w.clientsMutex.RLock()
184+ cfg := w.config
185+ w.clientsMutex.RUnlock()
186+
187+ if w.reloadCallback != nil && cfg != nil {
188+ log.Debugf("triggering server update callback after Kiro IDE token change")
189+ w.reloadCallback(cfg)
190+ }
191+}
192+
127193 func (w *Watcher) authFileUnchanged(path string) (bool, error) {
128194 data, errRead := os.ReadFile(path)
129195 if errRead != nil {
internal/watcher/synthesizer/config.go+97−0
@@ -4,8 +4,10 @@ import ( · 3 unmodified lines
44 "fmt"
55 "strings"
66
7+ kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
78 "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
89 coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
10+ log "github.com/sirupsen/logrus"
911 )
1012
1113 // ConfigSynthesizer generates Auth entries from configuration API keys.
@@ -30,6 +32,8 @@ func (s *ConfigSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, · 18 unmodified lines
3032 out = append(out, s.synthesizeClaudeKeys(ctx)...)
3133 // Codex API Keys
3234 out = append(out, s.synthesizeCodexKeys(ctx)...)
35+ // Kiro (AWS CodeWhisperer)
36+ out = append(out, s.synthesizeKiroKeys(ctx)...)
3337 // OpenAI-compat
3438 out = append(out, s.synthesizeOpenAICompat(ctx)...)
3539 // Vertex-compat
@@ -298,3 +302,96 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor · 262 unmodified lines
298302 }
299303 return out
300304 }
305+
306+// synthesizeKiroKeys creates Auth entries for Kiro (AWS CodeWhisperer) tokens.
307+func (s *ConfigSynthesizer) synthesizeKiroKeys(ctx *SynthesisContext) []*coreauth.Auth {
308+ cfg := ctx.Config
309+ now := ctx.Now
310+ idGen := ctx.IDGenerator
311+
312+ if len(cfg.KiroKey) == 0 {
313+ return nil
314+ }
315+
316+ out := make([]*coreauth.Auth, 0, len(cfg.KiroKey))
317+ kAuth := kiroauth.NewKiroAuth(cfg)
318+
319+ for i := range cfg.KiroKey {
320+ kk := cfg.KiroKey[i]
321+ var accessToken, profileArn, refreshToken string
322+
323+ // Try to load from token file first
324+ if kk.TokenFile != "" && kAuth != nil {
325+ tokenData, err := kAuth.LoadTokenFromFile(kk.TokenFile)
326+ if err != nil {
327+ log.Warnf("failed to load kiro token file %s: %v", kk.TokenFile, err)
328+ } else {
329+ accessToken = tokenData.AccessToken
330+ profileArn = tokenData.ProfileArn
331+ refreshToken = tokenData.RefreshToken
332+ }
333+ }
334+
335+ // Override with direct config values if provided
336+ if kk.AccessToken != "" {
337+ accessToken = kk.AccessToken
338+ }
339+ if kk.ProfileArn != "" {
340+ profileArn = kk.ProfileArn
341+ }
342+ if kk.RefreshToken != "" {
343+ refreshToken = kk.RefreshToken
344+ }
345+
346+ if accessToken == "" {
347+ log.Warnf("kiro config[%d] missing access_token, skipping", i)
348+ continue
349+ }
350+
351+ // profileArn is optional for AWS Builder ID users
352+ id, token := idGen.Next("kiro:token", accessToken, profileArn)
353+ attrs := map[string]string{
354+ "source": fmt.Sprintf("config:kiro[%s]", token),
355+ "access_token": accessToken,
356+ }
357+ if profileArn != "" {
358+ attrs["profile_arn"] = profileArn
359+ }
360+ if kk.Region != "" {
361+ attrs["region"] = kk.Region
362+ }
363+ if kk.AgentTaskType != "" {
364+ attrs["agent_task_type"] = kk.AgentTaskType
365+ }
366+ if kk.PreferredEndpoint != "" {
367+ attrs["preferred_endpoint"] = kk.PreferredEndpoint
368+ } else if cfg.KiroPreferredEndpoint != "" {
369+ // Apply global default if not overridden by specific key
370+ attrs["preferred_endpoint"] = cfg.KiroPreferredEndpoint
371+ }
372+ if refreshToken != "" {
373+ attrs["refresh_token"] = refreshToken
374+ }
375+ proxyURL := strings.TrimSpace(kk.ProxyURL)
376+ a := &coreauth.Auth{
377+ ID: id,
378+ Provider: "kiro",
379+ Label: "kiro-token",
380+ Status: coreauth.StatusActive,
381+ ProxyURL: proxyURL,
382+ Attributes: attrs,
383+ CreatedAt: now,
384+ UpdatedAt: now,
385+ }
386+
387+ if refreshToken != "" {
388+ if a.Metadata == nil {
389+ a.Metadata = make(map[string]any)
390+ }
391+ a.Metadata["refresh_token"] = refreshToken
392+ }
393+
394+ out = append(out, a)
395+ }
396+ return out
397+}
sdk/api/handlers/handlers.go+2−1
@@ -175,10 +175,11 @@ type BaseAPIHandler struct { · 174 unmodified lines
175175 // Returns:
176176 // - *BaseAPIHandler: A new API handlers instance
177177 func NewBaseAPIHandlers(cfg *config.SDKConfig, authManager *coreauth.Manager) *BaseAPIHandler {
178− return &BaseAPIHandler{
178+ h := &BaseAPIHandler{
179179 Cfg: cfg,
180180 AuthManager: authManager,
181181 }
182+ return h
182183 }
183184
184185 // UpdateClients updates the handlers' client list and configuration.
sdk/auth/github_copilot.go+129−0
@@ -0,0 +1,129 @@
1+package auth
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
9+ "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
10+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
12+ log "github.com/sirupsen/logrus"
13+)
14+
15+// GitHubCopilotAuthenticator implements the OAuth device flow login for GitHub Copilot.
16+type GitHubCopilotAuthenticator struct{}
17+
18+// NewGitHubCopilotAuthenticator constructs a new GitHub Copilot authenticator.
19+func NewGitHubCopilotAuthenticator() Authenticator {
20+ return &GitHubCopilotAuthenticator{}
21+}
22+
23+// Provider returns the provider key for github-copilot.
24+func (GitHubCopilotAuthenticator) Provider() string {
25+ return "github-copilot"
26+}
27+
28+// RefreshLead returns nil since GitHub OAuth tokens don't expire in the traditional sense.
29+// The token remains valid until the user revokes it or the Copilot subscription expires.
30+func (GitHubCopilotAuthenticator) RefreshLead() *time.Duration {
31+ return nil
32+}
33+
34+// Login initiates the GitHub device flow authentication for Copilot access.
35+func (a GitHubCopilotAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
36+ if cfg == nil {
37+ return nil, fmt.Errorf("cliproxy auth: configuration is required")
38+ }
39+ if opts == nil {
40+ opts = &LoginOptions{}
41+ }
42+
43+ authSvc := copilot.NewCopilotAuth(cfg)
44+
45+ // Start the device flow
46+ fmt.Println("Starting GitHub Copilot authentication...")
47+ deviceCode, err := authSvc.StartDeviceFlow(ctx)
48+ if err != nil {
49+ return nil, fmt.Errorf("github-copilot: failed to start device flow: %w", err)
50+ }
51+
52+ // Display the user code and verification URL
53+ fmt.Printf("\nTo authenticate, please visit: %s\n", deviceCode.VerificationURI)
54+ fmt.Printf("And enter the code: %s\n\n", deviceCode.UserCode)
55+
56+ // Try to open the browser automatically
57+ if !opts.NoBrowser {
58+ if browser.IsAvailable() {
59+ if errOpen := browser.OpenURL(deviceCode.VerificationURI); errOpen != nil {
60+ log.Warnf("Failed to open browser automatically: %v", errOpen)
61+ }
62+ }
63+ }
64+
65+ fmt.Println("Waiting for GitHub authorization...")
66+ fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn)
67+
68+ // Wait for user authorization
69+ authBundle, err := authSvc.WaitForAuthorization(ctx, deviceCode)
70+ if err != nil {
71+ errMsg := copilot.GetUserFriendlyMessage(err)
72+ return nil, fmt.Errorf("github-copilot: %s", errMsg)
73+ }
74+
75+ // Verify the token can get a Copilot API token
76+ fmt.Println("Verifying Copilot access...")
77+ apiToken, err := authSvc.GetCopilotAPIToken(ctx, authBundle.TokenData.AccessToken)
78+ if err != nil {
79+ return nil, fmt.Errorf("github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: %w", err)
80+ }
81+
82+ // Create the token storage
83+ tokenStorage := authSvc.CreateTokenStorage(authBundle)
84+
85+ // Build metadata with token information for the executor
86+ metadata := map[string]any{
87+ "type": "github-copilot",
88+ "username": authBundle.Username,
89+ "access_token": authBundle.TokenData.AccessToken,
90+ "token_type": authBundle.TokenData.TokenType,
91+ "scope": authBundle.TokenData.Scope,
92+ "timestamp": time.Now().UnixMilli(),
93+ }
94+
95+ if apiToken.ExpiresAt > 0 {
96+ metadata["api_token_expires_at"] = apiToken.ExpiresAt
97+ }
98+
99+ fileName := fmt.Sprintf("github-copilot-%s.json", authBundle.Username)
100+
101+ fmt.Printf("\nGitHub Copilot authentication successful for user: %s\n", authBundle.Username)
102+
103+ return &coreauth.Auth{
104+ ID: fileName,
105+ Provider: a.Provider(),
106+ FileName: fileName,
107+ Label: authBundle.Username,
108+ Storage: tokenStorage,
109+ Metadata: metadata,
110+ }, nil
111+}
112+
113+// RefreshGitHubCopilotToken validates and returns the current token status.
114+// GitHub OAuth tokens don't need traditional refresh - we just validate they still work.
115+func RefreshGitHubCopilotToken(ctx context.Context, cfg *config.Config, storage *copilot.CopilotTokenStorage) error {
116+ if storage == nil || storage.AccessToken == "" {
117+ return fmt.Errorf("no token available")
118+ }
119+
120+ authSvc := copilot.NewCopilotAuth(cfg)
121+
122+ // Validate the token can still get a Copilot API token
123+ _, err := authSvc.GetCopilotAPIToken(ctx, storage.AccessToken)
124+ if err != nil {
125+ return fmt.Errorf("token validation failed: %w", err)
126+ }
127+
128+ return nil
129+}
sdk/auth/kiro.go+470−0
@@ -0,0 +1,470 @@
1+package auth
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
10+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
12+)
13+
14+// extractKiroIdentifier extracts a meaningful identifier for file naming.
15+// Returns account name if provided, otherwise profile ARN ID.
16+// All extracted values are sanitized to prevent path injection attacks.
17+func extractKiroIdentifier(accountName, profileArn string) string {
18+ // Priority 1: Use account name if provided
19+ if accountName != "" {
20+ return kiroauth.SanitizeEmailForFilename(accountName)
21+ }
22+
23+ // Priority 2: Use profile ARN ID part (sanitized to prevent path injection)
24+ if profileArn != "" {
25+ parts := strings.Split(profileArn, "/")
26+ if len(parts) >= 2 {
27+ // Sanitize the ARN component to prevent path traversal
28+ return kiroauth.SanitizeEmailForFilename(parts[len(parts)-1])
29+ }
30+ }
31+
32+ // Fallback: timestamp
33+ return fmt.Sprintf("%d", time.Now().UnixNano()%100000)
34+}
35+
36+// KiroAuthenticator implements OAuth authentication for Kiro with Google login.
37+type KiroAuthenticator struct{}
38+
39+// NewKiroAuthenticator constructs a Kiro authenticator.
40+func NewKiroAuthenticator() *KiroAuthenticator {
41+ return &KiroAuthenticator{}
42+}
43+
44+// Provider returns the provider key for the authenticator.
45+func (a *KiroAuthenticator) Provider() string {
46+ return "kiro"
47+}
48+
49+// RefreshLead indicates how soon before expiry a refresh should be attempted.
50+// Set to 5 minutes to match Antigravity and avoid frequent refresh checks while still ensuring timely token refresh.
51+func (a *KiroAuthenticator) RefreshLead() *time.Duration {
52+ d := 5 * time.Minute
53+ return &d
54+}
55+
56+// createAuthRecord creates an auth record from token data.
57+func (a *KiroAuthenticator) createAuthRecord(tokenData *kiroauth.KiroTokenData, source string) (*coreauth.Auth, error) {
58+ // Parse expires_at
59+ expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
60+ if err != nil {
61+ expiresAt = time.Now().Add(1 * time.Hour)
62+ }
63+
64+ // Extract identifier for file naming
65+ idPart := extractKiroIdentifier(tokenData.Email, tokenData.ProfileArn)
66+
67+ // Determine label based on auth method
68+ label := fmt.Sprintf("kiro-%s", source)
69+ if tokenData.AuthMethod == "idc" {
70+ label = "kiro-idc"
71+ }
72+
73+ now := time.Now()
74+ fileName := fmt.Sprintf("%s-%s.json", label, idPart)
75+
76+ metadata := map[string]any{
77+ "type": "kiro",
78+ "access_token": tokenData.AccessToken,
79+ "refresh_token": tokenData.RefreshToken,
80+ "profile_arn": tokenData.ProfileArn,
81+ "expires_at": tokenData.ExpiresAt,
82+ "auth_method": tokenData.AuthMethod,
83+ "provider": tokenData.Provider,
84+ "client_id": tokenData.ClientID,
85+ "client_secret": tokenData.ClientSecret,
86+ "email": tokenData.Email,
87+ }
88+
89+ // Add IDC-specific fields if present
90+ if tokenData.StartURL != "" {
91+ metadata["start_url"] = tokenData.StartURL
92+ }
93+ if tokenData.Region != "" {
94+ metadata["region"] = tokenData.Region
95+ }
96+
97+ attributes := map[string]string{
98+ "profile_arn": tokenData.ProfileArn,
99+ "source": source,
100+ "email": tokenData.Email,
101+ }
102+
103+ // Add IDC-specific attributes if present
104+ if tokenData.AuthMethod == "idc" {
105+ attributes["source"] = "aws-idc"
106+ if tokenData.StartURL != "" {
107+ attributes["start_url"] = tokenData.StartURL
108+ }
109+ if tokenData.Region != "" {
110+ attributes["region"] = tokenData.Region
111+ }
112+ }
113+
114+ record := &coreauth.Auth{
115+ ID: fileName,
116+ Provider: "kiro",
117+ FileName: fileName,
118+ Label: label,
119+ Status: coreauth.StatusActive,
120+ CreatedAt: now,
121+ UpdatedAt: now,
122+ Metadata: metadata,
123+ Attributes: attributes,
124+ // NextRefreshAfter is aligned with RefreshLead (5min)
125+ NextRefreshAfter: expiresAt.Add(-5 * time.Minute),
126+ }
127+
128+ if tokenData.Email != "" {
129+ fmt.Printf("\n✓ Kiro authentication completed successfully! (Account: %s)\n", tokenData.Email)
130+ } else {
131+ fmt.Println("\n✓ Kiro authentication completed successfully!")
132+ }
133+
134+ return record, nil
135+}
136+
137+// Login performs OAuth login for Kiro with AWS (Builder ID or IDC).
138+// This shows a method selection prompt and handles both flows.
139+func (a *KiroAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
140+ if cfg == nil {
141+ return nil, fmt.Errorf("kiro auth: configuration is required")
142+ }
143+
144+ // Use the unified method selection flow (Builder ID or IDC)
145+ ssoClient := kiroauth.NewSSOOIDCClient(cfg)
146+ tokenData, err := ssoClient.LoginWithMethodSelection(ctx)
147+ if err != nil {
148+ return nil, fmt.Errorf("login failed: %w", err)
149+ }
150+
151+ return a.createAuthRecord(tokenData, "aws")
152+}
153+
154+// LoginWithAuthCode performs OAuth login for Kiro with AWS Builder ID using authorization code flow.
155+// This provides a better UX than device code flow as it uses automatic browser callback.
156+func (a *KiroAuthenticator) LoginWithAuthCode(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
157+ if cfg == nil {
158+ return nil, fmt.Errorf("kiro auth: configuration is required")
159+ }
160+
161+ oauth := kiroauth.NewKiroOAuth(cfg)
162+
163+ // Use AWS Builder ID authorization code flow
164+ tokenData, err := oauth.LoginWithBuilderIDAuthCode(ctx)
165+ if err != nil {
166+ return nil, fmt.Errorf("login failed: %w", err)
167+ }
168+
169+ // Parse expires_at
170+ expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
171+ if err != nil {
172+ expiresAt = time.Now().Add(1 * time.Hour)
173+ }
174+
175+ // Extract identifier for file naming
176+ idPart := extractKiroIdentifier(tokenData.Email, tokenData.ProfileArn)
177+
178+ now := time.Now()
179+ fileName := fmt.Sprintf("kiro-aws-%s.json", idPart)
180+
181+ record := &coreauth.Auth{
182+ ID: fileName,
183+ Provider: "kiro",
184+ FileName: fileName,
185+ Label: "kiro-aws",
186+ Status: coreauth.StatusActive,
187+ CreatedAt: now,
188+ UpdatedAt: now,
189+ Metadata: map[string]any{
190+ "type": "kiro",
191+ "access_token": tokenData.AccessToken,
192+ "refresh_token": tokenData.RefreshToken,
193+ "profile_arn": tokenData.ProfileArn,
194+ "expires_at": tokenData.ExpiresAt,
195+ "auth_method": tokenData.AuthMethod,
196+ "provider": tokenData.Provider,
197+ "client_id": tokenData.ClientID,
198+ "client_secret": tokenData.ClientSecret,
199+ "email": tokenData.Email,
200+ },
201+ Attributes: map[string]string{
202+ "profile_arn": tokenData.ProfileArn,
203+ "source": "aws-builder-id-authcode",
204+ "email": tokenData.Email,
205+ },
206+ // NextRefreshAfter is aligned with RefreshLead (5min)
207+ NextRefreshAfter: expiresAt.Add(-5 * time.Minute),
208+ }
209+
210+ if tokenData.Email != "" {
211+ fmt.Printf("\n✓ Kiro authentication completed successfully! (Account: %s)\n", tokenData.Email)
212+ } else {
213+ fmt.Println("\n✓ Kiro authentication completed successfully!")
214+ }
215+
216+ return record, nil
217+}
218+
219+// LoginWithGoogle performs OAuth login for Kiro with Google.
220+// This uses a custom protocol handler (kiro://) to receive the callback.
221+func (a *KiroAuthenticator) LoginWithGoogle(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
222+ if cfg == nil {
223+ return nil, fmt.Errorf("kiro auth: configuration is required")
224+ }
225+
226+ oauth := kiroauth.NewKiroOAuth(cfg)
227+
228+ // Use Google OAuth flow with protocol handler
229+ tokenData, err := oauth.LoginWithGoogle(ctx)
230+ if err != nil {
231+ return nil, fmt.Errorf("google login failed: %w", err)
232+ }
233+
234+ // Parse expires_at
235+ expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
236+ if err != nil {
237+ expiresAt = time.Now().Add(1 * time.Hour)
238+ }
239+
240+ // Extract identifier for file naming
241+ idPart := extractKiroIdentifier(tokenData.Email, tokenData.ProfileArn)
242+
243+ now := time.Now()
244+ fileName := fmt.Sprintf("kiro-google-%s.json", idPart)
245+
246+ record := &coreauth.Auth{
247+ ID: fileName,
248+ Provider: "kiro",
249+ FileName: fileName,
250+ Label: "kiro-google",
251+ Status: coreauth.StatusActive,
252+ CreatedAt: now,
253+ UpdatedAt: now,
254+ Metadata: map[string]any{
255+ "type": "kiro",
256+ "access_token": tokenData.AccessToken,
257+ "refresh_token": tokenData.RefreshToken,
258+ "profile_arn": tokenData.ProfileArn,
259+ "expires_at": tokenData.ExpiresAt,
260+ "auth_method": tokenData.AuthMethod,
261+ "provider": tokenData.Provider,
262+ "email": tokenData.Email,
263+ },
264+ Attributes: map[string]string{
265+ "profile_arn": tokenData.ProfileArn,
266+ "source": "google-oauth",
267+ "email": tokenData.Email,
268+ },
269+ // NextRefreshAfter is aligned with RefreshLead (5min)
270+ NextRefreshAfter: expiresAt.Add(-5 * time.Minute),
271+ }
272+
273+ if tokenData.Email != "" {
274+ fmt.Printf("\n✓ Kiro Google authentication completed successfully! (Account: %s)\n", tokenData.Email)
275+ } else {
276+ fmt.Println("\n✓ Kiro Google authentication completed successfully!")
277+ }
278+
279+ return record, nil
280+}
281+
282+// LoginWithGitHub performs OAuth login for Kiro with GitHub.
283+// This uses a custom protocol handler (kiro://) to receive the callback.
284+func (a *KiroAuthenticator) LoginWithGitHub(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
285+ if cfg == nil {
286+ return nil, fmt.Errorf("kiro auth: configuration is required")
287+ }
288+
289+ oauth := kiroauth.NewKiroOAuth(cfg)
290+
291+ // Use GitHub OAuth flow with protocol handler
292+ tokenData, err := oauth.LoginWithGitHub(ctx)
293+ if err != nil {
294+ return nil, fmt.Errorf("github login failed: %w", err)
295+ }
296+
297+ // Parse expires_at
298+ expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
299+ if err != nil {
300+ expiresAt = time.Now().Add(1 * time.Hour)
301+ }
302+
303+ // Extract identifier for file naming
304+ idPart := extractKiroIdentifier(tokenData.Email, tokenData.ProfileArn)
305+
306+ now := time.Now()
307+ fileName := fmt.Sprintf("kiro-github-%s.json", idPart)
308+
309+ record := &coreauth.Auth{
310+ ID: fileName,
311+ Provider: "kiro",
312+ FileName: fileName,
313+ Label: "kiro-github",
314+ Status: coreauth.StatusActive,
315+ CreatedAt: now,
316+ UpdatedAt: now,
317+ Metadata: map[string]any{
318+ "type": "kiro",
319+ "access_token": tokenData.AccessToken,
320+ "refresh_token": tokenData.RefreshToken,
321+ "profile_arn": tokenData.ProfileArn,
322+ "expires_at": tokenData.ExpiresAt,
323+ "auth_method": tokenData.AuthMethod,
324+ "provider": tokenData.Provider,
325+ "email": tokenData.Email,
326+ },
327+ Attributes: map[string]string{
328+ "profile_arn": tokenData.ProfileArn,
329+ "source": "github-oauth",
330+ "email": tokenData.Email,
331+ },
332+ // NextRefreshAfter is aligned with RefreshLead (5min)
333+ NextRefreshAfter: expiresAt.Add(-5 * time.Minute),
334+ }
335+
336+ if tokenData.Email != "" {
337+ fmt.Printf("\n✓ Kiro GitHub authentication completed successfully! (Account: %s)\n", tokenData.Email)
338+ } else {
339+ fmt.Println("\n✓ Kiro GitHub authentication completed successfully!")
340+ }
341+
342+ return record, nil
343+}
344+
345+// ImportFromKiroIDE imports token from Kiro IDE's token file.
346+func (a *KiroAuthenticator) ImportFromKiroIDE(ctx context.Context, cfg *config.Config) (*coreauth.Auth, error) {
347+ tokenData, err := kiroauth.LoadKiroIDEToken()
348+ if err != nil {
349+ return nil, fmt.Errorf("failed to load Kiro IDE token: %w", err)
350+ }
351+
352+ // Parse expires_at
353+ expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
354+ if err != nil {
355+ expiresAt = time.Now().Add(1 * time.Hour)
356+ }
357+
358+ // Extract email from JWT if not already set (for imported tokens)
359+ if tokenData.Email == "" {
360+ tokenData.Email = kiroauth.ExtractEmailFromJWT(tokenData.AccessToken)
361+ }
362+
363+ // Extract identifier for file naming
364+ idPart := extractKiroIdentifier(tokenData.Email, tokenData.ProfileArn)
365+ // Sanitize provider to prevent path traversal (defense-in-depth)
366+ provider := kiroauth.SanitizeEmailForFilename(strings.ToLower(strings.TrimSpace(tokenData.Provider)))
367+ if provider == "" {
368+ provider = "imported" // Fallback for legacy tokens without provider
369+ }
370+
371+ now := time.Now()
372+ fileName := fmt.Sprintf("kiro-%s-%s.json", provider, idPart)
373+
374+ record := &coreauth.Auth{
375+ ID: fileName,
376+ Provider: "kiro",
377+ FileName: fileName,
378+ Label: fmt.Sprintf("kiro-%s", provider),
379+ Status: coreauth.StatusActive,
380+ CreatedAt: now,
381+ UpdatedAt: now,
382+ Metadata: map[string]any{
383+ "type": "kiro",
384+ "access_token": tokenData.AccessToken,
385+ "refresh_token": tokenData.RefreshToken,
386+ "profile_arn": tokenData.ProfileArn,
387+ "expires_at": tokenData.ExpiresAt,
388+ "auth_method": tokenData.AuthMethod,
389+ "provider": tokenData.Provider,
390+ "email": tokenData.Email,
391+ },
392+ Attributes: map[string]string{
393+ "profile_arn": tokenData.ProfileArn,
394+ "source": "kiro-ide-import",
395+ "email": tokenData.Email,
396+ },
397+ // NextRefreshAfter is aligned with RefreshLead (5min)
398+ NextRefreshAfter: expiresAt.Add(-5 * time.Minute),
399+ }
400+
401+ // Display the email if extracted
402+ if tokenData.Email != "" {
403+ fmt.Printf("\n✓ Imported Kiro token from IDE (Provider: %s, Account: %s)\n", tokenData.Provider, tokenData.Email)
404+ } else {
405+ fmt.Printf("\n✓ Imported Kiro token from IDE (Provider: %s)\n", tokenData.Provider)
406+ }
407+
408+ return record, nil
409+}
410+
411+// Refresh refreshes an expired Kiro token using AWS SSO OIDC.
412+func (a *KiroAuthenticator) Refresh(ctx context.Context, cfg *config.Config, auth *coreauth.Auth) (*coreauth.Auth, error) {
413+ if auth == nil || auth.Metadata == nil {
414+ return nil, fmt.Errorf("invalid auth record")
415+ }
416+
417+ refreshToken, ok := auth.Metadata["refresh_token"].(string)
418+ if !ok || refreshToken == "" {
419+ return nil, fmt.Errorf("refresh token not found")
420+ }
421+
422+ clientID, _ := auth.Metadata["client_id"].(string)
423+ clientSecret, _ := auth.Metadata["client_secret"].(string)
424+ authMethod, _ := auth.Metadata["auth_method"].(string)
425+ startURL, _ := auth.Metadata["start_url"].(string)
426+ region, _ := auth.Metadata["region"].(string)
427+
428+ var tokenData *kiroauth.KiroTokenData
429+ var err error
430+
431+ ssoClient := kiroauth.NewSSOOIDCClient(cfg)
432+
433+ // Use SSO OIDC refresh for AWS Builder ID or IDC, otherwise use Kiro's OAuth refresh endpoint
434+ switch {
435+ case clientID != "" && clientSecret != "" && authMethod == "idc" && region != "":
436+ // IDC refresh with region-specific endpoint
437+ tokenData, err = ssoClient.RefreshTokenWithRegion(ctx, clientID, clientSecret, refreshToken, region, startURL)
438+ case clientID != "" && clientSecret != "" && authMethod == "builder-id":
439+ // Builder ID refresh with default endpoint
440+ tokenData, err = ssoClient.RefreshToken(ctx, clientID, clientSecret, refreshToken)
441+ default:
442+ // Fallback to Kiro's refresh endpoint (for social auth: Google/GitHub)
443+ oauth := kiroauth.NewKiroOAuth(cfg)
444+ tokenData, err = oauth.RefreshToken(ctx, refreshToken)
445+ }
446+
447+ if err != nil {
448+ return nil, fmt.Errorf("token refresh failed: %w", err)
449+ }
450+
451+ // Parse expires_at
452+ expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
453+ if err != nil {
454+ expiresAt = time.Now().Add(1 * time.Hour)
455+ }
456+
457+ // Clone auth to avoid mutating the input parameter
458+ updated := auth.Clone()
459+ now := time.Now()
460+ updated.UpdatedAt = now
461+ updated.LastRefreshedAt = now
462+ updated.Metadata["access_token"] = tokenData.AccessToken
463+ updated.Metadata["refresh_token"] = tokenData.RefreshToken
464+ updated.Metadata["expires_at"] = tokenData.ExpiresAt
465+ updated.Metadata["last_refresh"] = now.Format(time.RFC3339) // For double-check optimization
466+ // NextRefreshAfter is aligned with RefreshLead (5min)
467+ updated.NextRefreshAfter = expiresAt.Add(-5 * time.Minute)
468+
469+ return updated, nil
470+}
sdk/auth/manager.go+13−0
@@ -74,3 +74,16 @@ func (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config · 73 unmodified lines
7474 }
7575 return record, savedPath, nil
7676 }
77+
78+// SaveAuth persists an auth record directly without going through the login flow.
79+func (m *Manager) SaveAuth(record *coreauth.Auth, cfg *config.Config) (string, error) {
80+ if m.store == nil {
81+ return "", fmt.Errorf("no store configured")
82+ }
83+ if cfg != nil {
84+ if dirSetter, ok := m.store.(interface{ SetBaseDir(string) }); ok {
85+ dirSetter.SetBaseDir(cfg.AuthDir)
86+ }
87+ }
88+ return m.store.Save(context.Background(), record)
89+}
sdk/auth/refresh_registry.go+2−0
@@ -14,6 +14,8 @@ func init() { · 13 unmodified lines
1414 registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
1515 registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
1616 registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
17+ registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })
18+ registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
1719 }
1820
1921 func registerRefreshLead(provider string, factory func() Authenticator) {
sdk/cliproxy/auth/conductor.go+4−2
@@ -41,7 +41,7 @@ type RefreshEvaluator interface { · 40 unmodified lines
4141 const (
4242 refreshCheckInterval = 5 * time.Second
4343 refreshPendingBackoff = time.Minute
44− refreshFailureBackoff = 5 * time.Minute
44+ refreshFailureBackoff = 1 * time.Minute
4545 quotaBackoffBase = time.Second
4646 quotaBackoffMax = 30 * time.Minute
4747 )
@@ -1569,7 +1569,9 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) { · 1521 unmodified lines
15691569 updated.Runtime = auth.Runtime
15701570 }
15711571 updated.LastRefreshedAt = now
1572− updated.NextRefreshAfter = time.Time{}
1572+ // Preserve NextRefreshAfter set by the Authenticator
1573+ // If the Authenticator set a reasonable refresh time, it should not be overwritten
1574+ // If the Authenticator did not set it (zero value), shouldRefresh will use default logic
15731575 updated.LastError = nil
15741576 updated.UpdatedAt = now
15751577 _, _ = m.Update(ctx, updated)
sdk/cliproxy/service.go+9−0
@@ -379,6 +379,10 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { · 378 unmodified lines
379379 s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg))
380380 case "iflow":
381381 s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
382+ case "kiro":
383+ s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg))
384+ case "github-copilot":
385+ s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
382386 default:
383387 providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
384388 if providerKey == "" {
@@ -765,6 +769,11 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 380 unmodified lines
765769 models = applyExcludedModels(models, excluded)
766770 case "iflow":
767771 models = registry.GetIFlowModels()
772+ case "github-copilot":
773+ models = registry.GetGitHubCopilotModels()
774+ models = applyExcludedModels(models, excluded)
775+ case "kiro":
776+ models = registry.GetKiroModels()
768777 models = applyExcludedModels(models, excluded)
769778 default:
770779 // Handle OpenAI-compatibility providers by name using config

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t