feat: Kiro API Proxy - OpenAI/Anthropic compatible API service

- Multi-account pool with round-robin load balancing
- Auto token refresh for IAM IdC and Social auth
- Streaming support (SSE)
- Web admin panel with account management
- Docker support with GitHub Actions CI/CD
- Machine ID management per account
- Usage tracking (requests, tokens, credits)
This commit is contained in:
Quorinex
2026-02-04 00:37:05 +08:00
commit c5e6d42163
18 changed files with 5218 additions and 0 deletions

60
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Build Docker Image
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
branches: [main, master]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/kiro-api-proxy
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o kiro-api-proxy .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/kiro-api-proxy .
COPY --from=builder /app/web ./web
EXPOSE 8080
VOLUME /app/data
CMD ["./kiro-api-proxy"]

189
README.md Normal file
View File

@@ -0,0 +1,189 @@
# Kiro API Proxy
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://go.dev/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?style=flat&logo=docker)](https://www.docker.com/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
Convert Kiro accounts to OpenAI / Anthropic compatible API service.
[English](README.md) | [中文](README_CN.md)
## Features
- 🔄 **Anthropic Claude API** - Full support for `/v1/messages` endpoint
- 🤖 **OpenAI Chat API** - Compatible with `/v1/chat/completions`
- ⚖️ **Multi-Account Pool** - Round-robin load balancing
- 🔐 **Auto Token Refresh** - Seamless token management
- 📡 **Streaming** - Real-time SSE responses
- 🎛️ **Web Admin Panel** - Easy account management
- 🔑 **Multiple Auth Methods** - IAM SSO, SSO Token, Credentials import
- 📊 **Usage Tracking** - Monitor requests, tokens, and credits
## Quick Start
### Docker Compose (Recommended)
```bash
git clone https://github.com/Quorinex/kiro-api-proxy.git
cd kiro-api-proxy
# Create data directory for persistence
mkdir -p data
docker-compose up -d
```
### Docker Run
```bash
# Create data directory
mkdir -p /path/to/data
docker run -d \
--name kiro-api-proxy \
-p 8080:8080 \
-e ADMIN_PASSWORD=your_secure_password \
-v /path/to/data:/app/data \
--restart unless-stopped \
ghcr.io/quorinex/kiro-api-proxy:latest
```
> 📁 The `/app/data` volume stores `config.json` with accounts and settings. Mount it for data persistence.
### Build from Source
```bash
git clone https://github.com/Quorinex/kiro-api-proxy.git
cd kiro-api-proxy
go build -o kiro-api-proxy .
./kiro-api-proxy
```
## Configuration
Config file is auto-created at `data/config.json` on first run:
```json
{
"password": "changeme",
"port": 8080,
"host": "127.0.0.1",
"requireApiKey": false,
"apiKey": "",
"accounts": []
}
```
> ⚠️ **Change the default password before production use!**
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `CONFIG_PATH` | Config file path | `data/config.json` |
| `ADMIN_PASSWORD` | Admin panel password (overrides config) | - |
## Usage
### 1. Access Admin Panel
Open `http://localhost:8080/admin` and login with your password.
### 2. Add Accounts
Three methods available:
| Method | Description |
|--------|-------------|
| **IAM SSO** | For enterprise users with SSO Start URL |
| **SSO Token** | Import `x-amz-sso_authn` from browser |
| **Credentials** | Import JSON from Kiro Account Manager |
#### Credentials Format
```json
{
"refreshToken": "eyJ...",
"accessToken": "eyJ...",
"clientId": "xxx",
"clientSecret": "xxx"
}
```
### 3. Call API
#### Claude API
```bash
curl http://localhost:8080/v1/messages \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
#### OpenAI API
```bash
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer any" \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
## Model Mapping
| Request Model | Actual Model |
|---------------|--------------|
| `claude-sonnet-4-20250514` | claude-sonnet-4-20250514 |
| `claude-sonnet-4.5` | claude-sonnet-4.5 |
| `claude-haiku-4.5` | claude-haiku-4.5 |
| `claude-opus-4.5` | claude-opus-4.5 |
| `gpt-4o`, `gpt-4` | claude-sonnet-4-20250514 |
| `gpt-3.5-turbo` | claude-sonnet-4-20250514 |
## API Endpoints
| Endpoint | Description |
|----------|-------------|
| `GET /health` | Health check |
| `GET /v1/models` | List models |
| `POST /v1/messages` | Claude Messages API |
| `POST /v1/messages/count_tokens` | Token counting |
| `POST /v1/chat/completions` | OpenAI Chat API |
| `GET /admin` | Admin panel |
## Project Structure
```
kiro-api-proxy/
├── main.go # Entry point
├── config/ # Configuration management
├── pool/ # Account pool & load balancing
├── proxy/ # API handlers & Kiro client
│ ├── handler.go # HTTP routing & admin API
│ ├── kiro.go # Kiro API client
│ ├── kiro_api.go # Kiro REST API (usage, models)
│ └── translator.go # Request/response conversion
├── auth/ # Authentication
│ ├── iam_sso.go # IAM SSO login
│ ├── oidc.go # OIDC token refresh
│ └── sso_token.go # SSO token import
├── web/ # Admin panel frontend
├── Dockerfile
└── docker-compose.yml
```
## Disclaimer
This project is for educational and research purposes only. Please comply with Kiro's Terms of Service.
## License
[MIT](LICENSE)

189
README_CN.md Normal file
View File

@@ -0,0 +1,189 @@
# Kiro API Proxy
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://go.dev/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?style=flat&logo=docker)](https://www.docker.com/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
将 Kiro 账号转换为 OpenAI / Anthropic 兼容的 API 服务。
[English](README.md) | 中文
## 功能特性
- 🔄 **Anthropic Claude API** - 完整支持 `/v1/messages` 端点
- 🤖 **OpenAI Chat API** - 兼容 `/v1/chat/completions`
- ⚖️ **多账号池** - 轮询负载均衡
- 🔐 **自动刷新 Token** - 无缝 Token 管理
- 📡 **流式响应** - 实时 SSE 输出
- 🎛️ **Web 管理面板** - 便捷的账号管理
- 🔑 **多种认证方式** - IAM SSO、SSO Token、凭证导入
- 📊 **用量追踪** - 监控请求数、Token、Credits
## 快速开始
### Docker Compose推荐
```bash
git clone https://github.com/Quorinex/kiro-api-proxy.git
cd kiro-api-proxy
# 创建数据目录用于持久化
mkdir -p data
docker-compose up -d
```
### Docker 运行
```bash
# 创建数据目录
mkdir -p /path/to/data
docker run -d \
--name kiro-api-proxy \
-p 8080:8080 \
-e ADMIN_PASSWORD=your_secure_password \
-v /path/to/data:/app/data \
--restart unless-stopped \
ghcr.io/quorinex/kiro-api-proxy:latest
```
> 📁 `/app/data` 卷存储 `config.json`(包含账号和设置),挂载此目录以实现数据持久化。
### 源码编译
```bash
git clone https://github.com/Quorinex/kiro-api-proxy.git
cd kiro-api-proxy
go build -o kiro-api-proxy .
./kiro-api-proxy
```
## 配置
首次运行会自动创建 `data/config.json`
```json
{
"password": "changeme",
"port": 8080,
"host": "127.0.0.1",
"requireApiKey": false,
"apiKey": "",
"accounts": []
}
```
> ⚠️ **生产环境请务必修改默认密码!**
## 环境变量
| 变量 | 说明 | 默认值 |
|-----|------|-------|
| `CONFIG_PATH` | 配置文件路径 | `data/config.json` |
| `ADMIN_PASSWORD` | 管理面板密码(覆盖配置文件) | - |
## 使用方法
### 1. 访问管理面板
打开 `http://localhost:8080/admin`,输入密码登录。
### 2. 添加账号
支持三种方式:
| 方式 | 说明 |
|------|------|
| **IAM SSO** | 企业用户,输入 SSO Start URL |
| **SSO Token** | 从浏览器导入 `x-amz-sso_authn` |
| **凭证导入** | 从 Kiro Account Manager 导入 JSON |
#### 凭证格式
```json
{
"refreshToken": "eyJ...",
"accessToken": "eyJ...",
"clientId": "xxx",
"clientSecret": "xxx"
}
```
### 3. 调用 API
#### Claude API
```bash
curl http://localhost:8080/v1/messages \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "你好!"}]
}'
```
#### OpenAI API
```bash
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer any" \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "你好!"}]
}'
```
## 模型映射
| 请求模型 | 实际模型 |
|---------|---------|
| `claude-sonnet-4-20250514` | claude-sonnet-4-20250514 |
| `claude-sonnet-4.5` | claude-sonnet-4.5 |
| `claude-haiku-4.5` | claude-haiku-4.5 |
| `claude-opus-4.5` | claude-opus-4.5 |
| `gpt-4o`, `gpt-4` | claude-sonnet-4-20250514 |
| `gpt-3.5-turbo` | claude-sonnet-4-20250514 |
## API 端点
| 端点 | 说明 |
|-----|------|
| `GET /health` | 健康检查 |
| `GET /v1/models` | 模型列表 |
| `POST /v1/messages` | Claude Messages API |
| `POST /v1/messages/count_tokens` | Token 计数 |
| `POST /v1/chat/completions` | OpenAI Chat API |
| `GET /admin` | 管理面板 |
## 项目结构
```
kiro-api-proxy/
├── main.go # 入口
├── config/ # 配置管理
├── pool/ # 账号池 & 负载均衡
├── proxy/ # API 处理 & Kiro 客户端
│ ├── handler.go # HTTP 路由 & 管理 API
│ ├── kiro.go # Kiro API 客户端
│ ├── kiro_api.go # Kiro REST API用量、模型
│ └── translator.go # 请求/响应转换
├── auth/ # 认证
│ ├── iam_sso.go # IAM SSO 登录
│ ├── oidc.go # OIDC Token 刷新
│ └── sso_token.go # SSO Token 导入
├── web/ # 管理面板前端
├── Dockerfile
└── docker-compose.yml
```
## 免责声明
本项目仅供学习研究使用,请遵守 Kiro 服务条款。
## 许可证
[MIT](LICENSE)

267
auth/iam_sso.go Normal file
View File

@@ -0,0 +1,267 @@
package auth
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/google/uuid"
)
type IamSsoSession struct {
ClientID string
ClientSecret string
CodeVerifier string
State string
Region string
StartUrl string
RedirectUri string
ExpiresAt time.Time
}
var (
sessions = make(map[string]*IamSsoSession)
sessionsMu sync.RWMutex
)
var scopes = []string{
"codewhisperer:completions",
"codewhisperer:analysis",
"codewhisperer:conversations",
"codewhisperer:transformations",
"codewhisperer:taskassist",
}
// StartIamSsoLogin 发起 IAM SSO 登录
func StartIamSsoLogin(startUrl, region string) (sessionID, authorizeUrl string, expiresIn int, err error) {
if region == "" {
region = "us-east-1"
}
oidcBase := fmt.Sprintf("https://oidc.%s.amazonaws.com", region)
redirectUri := "http://127.0.0.1/oauth/callback"
// 1. 注册 OIDC 客户端
clientID, clientSecret, err := registerOIDCClient(oidcBase, startUrl, redirectUri)
if err != nil {
return "", "", 0, fmt.Errorf("注册客户端失败: %w", err)
}
// 2. 生成 PKCE
codeVerifier := generateCodeVerifier()
codeChallenge := generateCodeChallenge(codeVerifier)
state := uuid.New().String()
// 3. 构建授权 URL
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", clientID)
params.Set("redirect_uri", redirectUri)
params.Set("scopes", joinScopes())
params.Set("state", state)
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
authorizeUrl = fmt.Sprintf("%s/authorize?%s", oidcBase, params.Encode())
// 4. 保存会话
sessionID = uuid.New().String()
session := &IamSsoSession{
ClientID: clientID,
ClientSecret: clientSecret,
CodeVerifier: codeVerifier,
State: state,
Region: region,
StartUrl: startUrl,
RedirectUri: redirectUri,
ExpiresAt: time.Now().Add(10 * time.Minute),
}
sessionsMu.Lock()
sessions[sessionID] = session
sessionsMu.Unlock()
// 清理过期会话
go cleanupExpiredSessions()
return sessionID, authorizeUrl, 600, nil
}
// CompleteIamSsoLogin 完成 IAM SSO 登录
func CompleteIamSsoLogin(sessionID, callbackUrl string) (accessToken, refreshToken, clientID, clientSecret, region string, expiresIn int, err error) {
sessionsMu.RLock()
session, ok := sessions[sessionID]
sessionsMu.RUnlock()
if !ok {
return "", "", "", "", "", 0, fmt.Errorf("会话不存在或已过期")
}
if time.Now().After(session.ExpiresAt) {
sessionsMu.Lock()
delete(sessions, sessionID)
sessionsMu.Unlock()
return "", "", "", "", "", 0, fmt.Errorf("会话已过期")
}
// 解析回调 URL
parsedUrl, err := url.Parse(callbackUrl)
if err != nil {
return "", "", "", "", "", 0, fmt.Errorf("无效的回调 URL")
}
code := parsedUrl.Query().Get("code")
state := parsedUrl.Query().Get("state")
errorParam := parsedUrl.Query().Get("error")
if errorParam != "" {
return "", "", "", "", "", 0, fmt.Errorf("授权失败: %s", errorParam)
}
if state != session.State {
return "", "", "", "", "", 0, fmt.Errorf("状态不匹配,可能存在安全风险")
}
if code == "" {
return "", "", "", "", "", 0, fmt.Errorf("未收到授权码")
}
// 用 code 换取 token
oidcBase := fmt.Sprintf("https://oidc.%s.amazonaws.com", session.Region)
accessToken, refreshToken, expiresIn, err = exchangeToken(
oidcBase,
session.ClientID,
session.ClientSecret,
code,
session.CodeVerifier,
session.RedirectUri,
)
if err != nil {
return "", "", "", "", "", 0, err
}
// 清理会话
sessionsMu.Lock()
delete(sessions, sessionID)
sessionsMu.Unlock()
return accessToken, refreshToken, session.ClientID, session.ClientSecret, session.Region, expiresIn, nil
}
func registerOIDCClient(oidcBase, startUrl, redirectUri string) (clientID, clientSecret string, err error) {
payload := map[string]interface{}{
"clientName": "Kiro API Proxy",
"clientType": "public",
"scopes": scopes,
"grantTypes": []string{"authorization_code", "refresh_token"},
"redirectUris": []string{redirectUri},
"issuerUrl": startUrl,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", oidcBase+"/client/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", err
}
return result.ClientID, result.ClientSecret, nil
}
func exchangeToken(oidcBase, clientID, clientSecret, code, codeVerifier, redirectUri string) (accessToken, refreshToken string, expiresIn int, err error) {
payload := map[string]string{
"clientId": clientID,
"clientSecret": clientSecret,
"grantType": "authorization_code",
"redirectUri": redirectUri,
"code": code,
"codeVerifier": codeVerifier,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", oidcBase+"/token", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return "", "", 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", 0, err
}
return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil
}
func generateCodeVerifier() string {
b := make([]byte, 32)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func generateCodeChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
func joinScopes() string {
result := ""
for i, s := range scopes {
if i > 0 {
result += ","
}
result += s
}
return result
}
func cleanupExpiredSessions() {
sessionsMu.Lock()
defer sessionsMu.Unlock()
now := time.Now()
for id, s := range sessions {
if now.After(s.ExpiresAt) {
delete(sessions, id)
}
}
}

102
auth/oidc.go Normal file
View File

@@ -0,0 +1,102 @@
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"kiro-api-proxy/config"
"net/http"
"time"
)
// RefreshToken 刷新 access token
func RefreshToken(account *config.Account) (string, string, int64, error) {
if account.AuthMethod == "social" {
return refreshSocialToken(account.RefreshToken)
}
return refreshOIDCToken(account.RefreshToken, account.ClientID, account.ClientSecret, account.Region)
}
// refreshOIDCToken IdC/Builder ID token 刷新
func refreshOIDCToken(refreshToken, clientID, clientSecret, region string) (string, string, int64, error) {
if region == "" {
region = "us-east-1"
}
url := fmt.Sprintf("https://oidc.%s.amazonaws.com/token", region)
payload := map[string]string{
"clientId": clientID,
"clientSecret": clientSecret,
"refreshToken": refreshToken,
"grantType": "refresh_token",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return "", "", 0, fmt.Errorf("refresh failed: %d %s", resp.StatusCode, string(respBody))
}
var result struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", 0, err
}
expiresAt := time.Now().Unix() + int64(result.ExpiresIn)
return result.AccessToken, result.RefreshToken, expiresAt, nil
}
// refreshSocialToken Social (GitHub/Google) token 刷新
func refreshSocialToken(refreshToken string) (string, string, int64, error) {
url := "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"
payload := map[string]string{
"refreshToken": refreshToken,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return "", "", 0, fmt.Errorf("refresh failed: %d %s", resp.StatusCode, string(respBody))
}
var result struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", 0, err
}
expiresAt := time.Now().Unix() + int64(result.ExpiresIn)
return result.AccessToken, result.RefreshToken, expiresAt, nil
}

338
auth/sso_token.go Normal file
View File

@@ -0,0 +1,338 @@
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/google/uuid"
)
// ImportFromSsoToken 从 SSO Token (x-amz-sso_authn) 导入账号
func ImportFromSsoToken(bearerToken, region string) (accessToken, refreshToken, clientID, clientSecret string, expiresIn int, err error) {
if region == "" {
region = "us-east-1"
}
oidcBase := fmt.Sprintf("https://oidc.%s.amazonaws.com", region)
portalBase := "https://portal.sso.us-east-1.amazonaws.com"
startUrl := "https://view.awsapps.com/start"
// 1. 注册 OIDC 客户端
clientID, clientSecret, err = registerDeviceClient(oidcBase, startUrl)
if err != nil {
return "", "", "", "", 0, fmt.Errorf("注册客户端失败: %w", err)
}
// 2. 发起设备授权
deviceCode, userCode, interval, err := startDeviceAuth(oidcBase, clientID, clientSecret, startUrl)
if err != nil {
return "", "", "", "", 0, fmt.Errorf("设备授权失败: %w", err)
}
// 3. 验证 Bearer Token
if err := verifyBearerToken(portalBase, bearerToken); err != nil {
return "", "", "", "", 0, fmt.Errorf("Token 验证失败: %w", err)
}
// 4. 获取设备会话令牌
deviceSessionToken, err := getDeviceSessionToken(portalBase, bearerToken)
if err != nil {
return "", "", "", "", 0, fmt.Errorf("获取设备会话失败: %w", err)
}
// 5. 接受用户代码
deviceContext, err := acceptUserCode(oidcBase, userCode, deviceSessionToken)
if err != nil {
return "", "", "", "", 0, fmt.Errorf("接受用户代码失败: %w", err)
}
// 6. 批准授权
if deviceContext != nil {
if err := approveAuth(oidcBase, deviceContext, deviceSessionToken); err != nil {
return "", "", "", "", 0, fmt.Errorf("批准授权失败: %w", err)
}
}
// 7. 轮询获取 Token
accessToken, refreshToken, expiresIn, err = pollForToken(oidcBase, clientID, clientSecret, deviceCode, interval)
if err != nil {
return "", "", "", "", 0, fmt.Errorf("获取 Token 失败: %w", err)
}
return accessToken, refreshToken, clientID, clientSecret, expiresIn, nil
}
func registerDeviceClient(oidcBase, startUrl string) (clientID, clientSecret string, err error) {
payload := map[string]interface{}{
"clientName": "Kiro API Proxy",
"clientType": "public",
"scopes": scopes,
"grantTypes": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
"issuerUrl": startUrl,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", oidcBase+"/client/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
json.NewDecoder(resp.Body).Decode(&result)
return result.ClientID, result.ClientSecret, nil
}
func startDeviceAuth(oidcBase, clientID, clientSecret, startUrl string) (deviceCode, userCode string, interval int, err error) {
payload := map[string]string{
"clientId": clientID,
"clientSecret": clientSecret,
"startUrl": startUrl,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", oidcBase+"/device_authorization", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return "", "", 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
DeviceCode string `json:"deviceCode"`
UserCode string `json:"userCode"`
Interval int `json:"interval"`
}
json.NewDecoder(resp.Body).Decode(&result)
if result.Interval == 0 {
result.Interval = 1
}
return result.DeviceCode, result.UserCode, result.Interval, nil
}
func verifyBearerToken(portalBase, bearerToken string) error {
req, _ := http.NewRequest("GET", portalBase+"/token/whoAmI", nil)
req.Header.Set("Authorization", "Bearer "+bearerToken)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
}
func getDeviceSessionToken(portalBase, bearerToken string) (string, error) {
req, _ := http.NewRequest("POST", portalBase+"/session/device", bytes.NewReader([]byte("{}")))
req.Header.Set("Authorization", "Bearer "+bearerToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Token string `json:"token"`
}
json.NewDecoder(resp.Body).Decode(&result)
return result.Token, nil
}
type deviceContextInfo struct {
DeviceContextID string `json:"deviceContextId"`
ClientID string `json:"clientId"`
ClientType string `json:"clientType"`
}
func acceptUserCode(oidcBase, userCode, deviceSessionToken string) (*deviceContextInfo, error) {
payload := map[string]string{
"userCode": userCode,
"userSessionId": deviceSessionToken,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", oidcBase+"/device_authorization/accept_user_code", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://view.awsapps.com/")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
DeviceContext *deviceContextInfo `json:"deviceContext"`
}
json.NewDecoder(resp.Body).Decode(&result)
return result.DeviceContext, nil
}
func approveAuth(oidcBase string, deviceContext *deviceContextInfo, deviceSessionToken string) error {
payload := map[string]interface{}{
"deviceContext": map[string]string{
"deviceContextId": deviceContext.DeviceContextID,
"clientId": deviceContext.ClientID,
"clientType": deviceContext.ClientType,
},
"userSessionId": deviceSessionToken,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", oidcBase+"/device_authorization/associate_token", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://view.awsapps.com/")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func pollForToken(oidcBase, clientID, clientSecret, deviceCode string, interval int) (accessToken, refreshToken string, expiresIn int, err error) {
payload := map[string]string{
"clientId": clientID,
"clientSecret": clientSecret,
"grantType": "urn:ietf:params:oauth:grant-type:device_code",
"deviceCode": deviceCode,
}
timeout := time.After(2 * time.Minute)
ticker := time.NewTicker(time.Duration(interval) * time.Second)
defer ticker.Stop()
for {
select {
case <-timeout:
return "", "", 0, fmt.Errorf("授权超时")
case <-ticker.C:
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", oidcBase+"/token", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
continue
}
if resp.StatusCode == 200 {
var result struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"`
}
json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil
}
if resp.StatusCode == 400 {
var errResult struct {
Error string `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&errResult)
resp.Body.Close()
if errResult.Error == "authorization_pending" {
continue
} else if errResult.Error == "slow_down" {
interval += 5
ticker.Reset(time.Duration(interval) * time.Second)
continue
}
return "", "", 0, fmt.Errorf("授权错误: %s", errResult.Error)
}
resp.Body.Close()
}
}
}
// GetUserInfo 获取用户信息
func GetUserInfo(accessToken string) (email, userID string, err error) {
// 调用 Kiro API 获取用量信息(包含用户信息)
url := "https://q.us-east-1.amazonaws.com/getUsageLimits?origin=AI_EDITOR&resourceType=AGENTIC_REQUEST&isEmailRequired=true"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "aws-sdk-js/1.0.18 KiroAPIProxy")
req.Header.Set("x-amz-user-agent", "aws-sdk-js/1.0.18 KiroAPIProxy")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
var result struct {
UserInfo struct {
Email string `json:"email"`
UserID string `json:"userId"`
} `json:"userInfo"`
}
json.NewDecoder(resp.Body).Decode(&result)
return result.UserInfo.Email, result.UserInfo.UserID, nil
}
// GenerateAccountID 生成账号 ID
func GenerateAccountID() string {
return uuid.New().String()
}

331
config/config.go Normal file
View File

@@ -0,0 +1,331 @@
// Package config 配置管理模块
// 负责账号、设置、统计数据的持久化存储
package config
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"sync"
)
// GenerateMachineId 生成 UUID v4 格式的机器码
func GenerateMachineId() string {
bytes := make([]byte, 16)
rand.Read(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40 // 版本 4
bytes[8] = (bytes[8] & 0x3f) | 0x80 // 变体
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16])
}
// Account 账号信息
type Account struct {
// 基本信息
ID string `json:"id"`
Email string `json:"email,omitempty"`
UserId string `json:"userId,omitempty"`
Nickname string `json:"nickname,omitempty"`
// 认证信息
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ClientID string `json:"clientId,omitempty"`
ClientSecret string `json:"clientSecret,omitempty"`
AuthMethod string `json:"authMethod"` // idc | social
Provider string `json:"provider,omitempty"`
Region string `json:"region"`
StartUrl string `json:"startUrl,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
MachineId string `json:"machineId,omitempty"` // UUID 格式机器码
// 状态
Enabled bool `json:"enabled"`
// 订阅信息
SubscriptionType string `json:"subscriptionType,omitempty"` // FREE | PRO | PRO_PLUS | POWER
SubscriptionTitle string `json:"subscriptionTitle,omitempty"`
DaysRemaining int `json:"daysRemaining,omitempty"`
// 使用量
UsageCurrent float64 `json:"usageCurrent,omitempty"`
UsageLimit float64 `json:"usageLimit,omitempty"`
UsagePercent float64 `json:"usagePercent,omitempty"`
NextResetDate string `json:"nextResetDate,omitempty"`
LastRefresh int64 `json:"lastRefresh,omitempty"`
// 运行时统计
RequestCount int `json:"requestCount,omitempty"`
ErrorCount int `json:"errorCount,omitempty"`
LastUsed int64 `json:"lastUsed,omitempty"`
TotalTokens int `json:"totalTokens,omitempty"`
TotalCredits float64 `json:"totalCredits,omitempty"`
}
// Config 全局配置
type Config struct {
Password string `json:"password"`
Port int `json:"port"`
Host string `json:"host"`
ApiKey string `json:"apiKey,omitempty"`
RequireApiKey bool `json:"requireApiKey"`
Accounts []Account `json:"accounts"`
// 全局统计
TotalRequests int `json:"totalRequests,omitempty"`
SuccessRequests int `json:"successRequests,omitempty"`
FailedRequests int `json:"failedRequests,omitempty"`
TotalTokens int `json:"totalTokens,omitempty"`
TotalCredits float64 `json:"totalCredits,omitempty"`
}
// AccountInfo 账户信息更新结构
type AccountInfo struct {
Email string
UserId string
SubscriptionType string
SubscriptionTitle string
DaysRemaining int
UsageCurrent float64
UsageLimit float64
UsagePercent float64
NextResetDate string
LastRefresh int64
}
var (
cfg *Config
cfgLock sync.RWMutex
cfgPath string
)
// Init 初始化配置
func Init(path string) error {
cfgPath = path
return Load()
}
// Load 从文件加载配置
func Load() error {
cfgLock.Lock()
defer cfgLock.Unlock()
data, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
// 创建默认配置
cfg = &Config{
Password: "changeme",
Port: 8080,
Host: "127.0.0.1",
RequireApiKey: false,
Accounts: []Account{},
}
return Save()
}
return err
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return err
}
cfg = &c
return nil
}
// Save 保存配置到文件
func Save() error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(cfgPath, data, 0600)
}
// SetPassword 设置密码(用于环境变量覆盖)
func SetPassword(password string) {
cfgLock.Lock()
defer cfgLock.Unlock()
cfg.Password = password
}
func Get() *Config {
cfgLock.RLock()
defer cfgLock.RUnlock()
return cfg
}
func GetPassword() string {
cfgLock.RLock()
defer cfgLock.RUnlock()
return cfg.Password
}
func GetPort() int {
cfgLock.RLock()
defer cfgLock.RUnlock()
if cfg.Port == 0 {
return 8080
}
return cfg.Port
}
func GetHost() string {
cfgLock.RLock()
defer cfgLock.RUnlock()
if cfg.Host == "" {
return "127.0.0.1"
}
return cfg.Host
}
func GetAccounts() []Account {
cfgLock.RLock()
defer cfgLock.RUnlock()
accounts := make([]Account, len(cfg.Accounts))
copy(accounts, cfg.Accounts)
return accounts
}
func GetEnabledAccounts() []Account {
cfgLock.RLock()
defer cfgLock.RUnlock()
var accounts []Account
for _, a := range cfg.Accounts {
if a.Enabled {
accounts = append(accounts, a)
}
}
return accounts
}
func AddAccount(account Account) error {
cfgLock.Lock()
defer cfgLock.Unlock()
cfg.Accounts = append(cfg.Accounts, account)
return Save()
}
func UpdateAccount(id string, account Account) error {
cfgLock.Lock()
defer cfgLock.Unlock()
for i, a := range cfg.Accounts {
if a.ID == id {
cfg.Accounts[i] = account
return Save()
}
}
return nil
}
func DeleteAccount(id string) error {
cfgLock.Lock()
defer cfgLock.Unlock()
for i, a := range cfg.Accounts {
if a.ID == id {
cfg.Accounts = append(cfg.Accounts[:i], cfg.Accounts[i+1:]...)
return Save()
}
}
return nil
}
func UpdateAccountToken(id, accessToken, refreshToken string, expiresAt int64) error {
cfgLock.Lock()
defer cfgLock.Unlock()
for i, a := range cfg.Accounts {
if a.ID == id {
cfg.Accounts[i].AccessToken = accessToken
if refreshToken != "" {
cfg.Accounts[i].RefreshToken = refreshToken
}
cfg.Accounts[i].ExpiresAt = expiresAt
return Save()
}
}
return nil
}
func GetApiKey() string {
cfgLock.RLock()
defer cfgLock.RUnlock()
return cfg.ApiKey
}
func IsApiKeyRequired() bool {
cfgLock.RLock()
defer cfgLock.RUnlock()
return cfg.RequireApiKey
}
func UpdateSettings(apiKey string, requireApiKey bool, password string) error {
cfgLock.Lock()
defer cfgLock.Unlock()
cfg.ApiKey = apiKey
cfg.RequireApiKey = requireApiKey
if password != "" {
cfg.Password = password
}
return Save()
}
func UpdateStats(totalReq, successReq, failedReq, totalTokens int, totalCredits float64) error {
cfgLock.Lock()
defer cfgLock.Unlock()
cfg.TotalRequests = totalReq
cfg.SuccessRequests = successReq
cfg.FailedRequests = failedReq
cfg.TotalTokens = totalTokens
cfg.TotalCredits = totalCredits
return Save()
}
func GetStats() (int, int, int, int, float64) {
cfgLock.RLock()
defer cfgLock.RUnlock()
return cfg.TotalRequests, cfg.SuccessRequests, cfg.FailedRequests, cfg.TotalTokens, cfg.TotalCredits
}
func UpdateAccountStats(id string, requestCount, errorCount, totalTokens int, totalCredits float64, lastUsed int64) error {
cfgLock.Lock()
defer cfgLock.Unlock()
for i, a := range cfg.Accounts {
if a.ID == id {
cfg.Accounts[i].RequestCount = requestCount
cfg.Accounts[i].ErrorCount = errorCount
cfg.Accounts[i].TotalTokens = totalTokens
cfg.Accounts[i].TotalCredits = totalCredits
cfg.Accounts[i].LastUsed = lastUsed
return Save()
}
}
return nil
}
// UpdateAccountInfo 更新账户的订阅和使用量信息
func UpdateAccountInfo(id string, info AccountInfo) error {
cfgLock.Lock()
defer cfgLock.Unlock()
for i, a := range cfg.Accounts {
if a.ID == id {
if info.Email != "" {
cfg.Accounts[i].Email = info.Email
}
if info.UserId != "" {
cfg.Accounts[i].UserId = info.UserId
}
cfg.Accounts[i].SubscriptionType = info.SubscriptionType
cfg.Accounts[i].SubscriptionTitle = info.SubscriptionTitle
cfg.Accounts[i].DaysRemaining = info.DaysRemaining
cfg.Accounts[i].UsageCurrent = info.UsageCurrent
cfg.Accounts[i].UsageLimit = info.UsageLimit
cfg.Accounts[i].UsagePercent = info.UsagePercent
cfg.Accounts[i].NextResetDate = info.NextResetDate
cfg.Accounts[i].LastRefresh = info.LastRefresh
return Save()
}
}
return nil
}

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
version: '3.8'
services:
kiro-api-proxy:
build: .
ports:
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- CONFIG_PATH=/app/data/config.json
restart: unless-stopped

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module kiro-api-proxy
go 1.21
require github.com/google/uuid v1.6.0

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

54
main.go Normal file
View File

@@ -0,0 +1,54 @@
// Kiro API Proxy - 将 Kiro API 转换为 OpenAI/Anthropic 兼容格式
// 支持多账号池、自动 Token 刷新、流式响应
package main
import (
"fmt"
"kiro-api-proxy/config"
"kiro-api-proxy/pool"
"kiro-api-proxy/proxy"
"log"
"net/http"
"os"
"path/filepath"
)
func main() {
// 配置文件路径,支持环境变量覆盖
configPath := "data/config.json"
if envPath := os.Getenv("CONFIG_PATH"); envPath != "" {
configPath = envPath
}
// 确保数据目录存在
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
// 加载配置
if err := config.Init(configPath); err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// 环境变量覆盖密码
if envPassword := os.Getenv("ADMIN_PASSWORD"); envPassword != "" {
config.SetPassword(envPassword)
}
// 初始化账号池
pool.GetPool()
// 创建 HTTP 处理器(包含后台刷新任务)
handler := proxy.NewHandler()
// 启动服务器
addr := fmt.Sprintf("%s:%d", config.GetHost(), config.GetPort())
log.Printf("Kiro API Proxy starting on http://%s", addr)
log.Printf("Admin panel: http://%s/admin", addr)
log.Printf("Claude API: http://%s/v1/messages", addr)
log.Printf("OpenAI API: http://%s/v1/chat/completions", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("Server failed: %v", err)
}
}

189
pool/account.go Normal file
View File

@@ -0,0 +1,189 @@
// Package pool 账号池管理
// 实现轮询负载均衡、错误冷却、Token 刷新
package pool
import (
"kiro-api-proxy/config"
"sync"
"sync/atomic"
"time"
)
// AccountPool 账号池
type AccountPool struct {
mu sync.RWMutex
accounts []config.Account
currentIndex uint64
cooldowns map[string]time.Time // 账号冷却时间
errorCounts map[string]int // 连续错误计数
}
var (
pool *AccountPool
poolOnce sync.Once
)
// GetPool 获取全局账号池单例
func GetPool() *AccountPool {
poolOnce.Do(func() {
pool = &AccountPool{
cooldowns: make(map[string]time.Time),
errorCounts: make(map[string]int),
}
pool.Reload()
})
return pool
}
// Reload 从配置重新加载账号
func (p *AccountPool) Reload() {
p.mu.Lock()
defer p.mu.Unlock()
p.accounts = config.GetEnabledAccounts()
}
// GetNext 获取下一个可用账号(轮询)
func (p *AccountPool) GetNext() *config.Account {
p.mu.RLock()
defer p.mu.RUnlock()
if len(p.accounts) == 0 {
return nil
}
now := time.Now()
n := len(p.accounts)
// 轮询查找可用账号
for i := 0; i < n; i++ {
idx := atomic.AddUint64(&p.currentIndex, 1) % uint64(n)
acc := &p.accounts[idx]
// 跳过冷却中的账号
if cooldown, ok := p.cooldowns[acc.ID]; ok && now.Before(cooldown) {
continue
}
// 跳过即将过期的 Token
if acc.ExpiresAt > 0 && time.Now().Unix() > acc.ExpiresAt-300 {
continue
}
return acc
}
// 无可用账号,返回冷却时间最短的
var best *config.Account
var earliest time.Time
for i := range p.accounts {
acc := &p.accounts[i]
if cooldown, ok := p.cooldowns[acc.ID]; ok {
if best == nil || cooldown.Before(earliest) {
best = acc
earliest = cooldown
}
} else {
return acc
}
}
return best
}
// GetByID 根据 ID 获取账号
func (p *AccountPool) GetByID(id string) *config.Account {
p.mu.RLock()
defer p.mu.RUnlock()
for i := range p.accounts {
if p.accounts[i].ID == id {
return &p.accounts[i]
}
}
return nil
}
// RecordSuccess 记录请求成功,清除冷却
func (p *AccountPool) RecordSuccess(id string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.cooldowns, id)
p.errorCounts[id] = 0
}
// RecordError 记录请求错误,设置冷却
func (p *AccountPool) RecordError(id string, isQuotaError bool) {
p.mu.Lock()
defer p.mu.Unlock()
p.errorCounts[id]++
if isQuotaError {
// 配额错误,冷却 1 小时
p.cooldowns[id] = time.Now().Add(time.Hour)
} else if p.errorCounts[id] >= 3 {
// 连续 3 次错误,冷却 1 分钟
p.cooldowns[id] = time.Now().Add(time.Minute)
}
}
// UpdateToken 更新账号 Token
func (p *AccountPool) UpdateToken(id, accessToken, refreshToken string, expiresAt int64) {
p.mu.Lock()
defer p.mu.Unlock()
for i := range p.accounts {
if p.accounts[i].ID == id {
p.accounts[i].AccessToken = accessToken
if refreshToken != "" {
p.accounts[i].RefreshToken = refreshToken
}
p.accounts[i].ExpiresAt = expiresAt
break
}
}
}
// Count 返回账号总数
func (p *AccountPool) Count() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.accounts)
}
// AvailableCount 返回可用账号数
func (p *AccountPool) AvailableCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
now := time.Now()
count := 0
for _, acc := range p.accounts {
if cooldown, ok := p.cooldowns[acc.ID]; ok && now.Before(cooldown) {
continue
}
count++
}
return count
}
// UpdateStats 更新账号统计
func (p *AccountPool) UpdateStats(id string, tokens int, credits float64) {
p.mu.Lock()
defer p.mu.Unlock()
for i := range p.accounts {
if p.accounts[i].ID == id {
p.accounts[i].RequestCount++
p.accounts[i].TotalTokens += tokens
p.accounts[i].TotalCredits += credits
p.accounts[i].LastUsed = time.Now().Unix()
go config.UpdateAccountStats(id, p.accounts[i].RequestCount, p.accounts[i].ErrorCount, p.accounts[i].TotalTokens, p.accounts[i].TotalCredits, p.accounts[i].LastUsed)
break
}
}
}
// GetAllAccounts 获取所有账号副本
func (p *AccountPool) GetAllAccounts() []config.Account {
p.mu.RLock()
defer p.mu.RUnlock()
result := make([]config.Account, len(p.accounts))
copy(result, p.accounts)
return result
}

1392
proxy/handler.go Normal file

File diff suppressed because it is too large Load Diff

370
proxy/kiro.go Normal file
View File

@@ -0,0 +1,370 @@
// Package proxy Kiro API 代理核心
// 负责调用 Kiro API 并解析 AWS Event Stream 响应
package proxy
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"kiro-api-proxy/config"
"net/http"
"strings"
"time"
"github.com/google/uuid"
)
const (
KiroEndpoint = "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse"
KiroVersion = "0.6.18"
)
// ==================== 请求结构 ====================
// KiroPayload Kiro API 请求体
type KiroPayload struct {
ConversationState struct {
ChatTriggerType string `json:"chatTriggerType"`
ConversationID string `json:"conversationId"`
CurrentMessage struct {
UserInputMessage KiroUserInputMessage `json:"userInputMessage"`
} `json:"currentMessage"`
History []KiroHistoryMessage `json:"history,omitempty"`
} `json:"conversationState"`
ProfileArn string `json:"profileArn,omitempty"`
InferenceConfig *InferenceConfig `json:"inferenceConfig,omitempty"`
}
type KiroUserInputMessage struct {
Content string `json:"content"`
ModelID string `json:"modelId,omitempty"`
Origin string `json:"origin"`
Images []KiroImage `json:"images,omitempty"`
UserInputMessageContext *UserInputMessageContext `json:"userInputMessageContext,omitempty"`
}
type UserInputMessageContext struct {
Tools []KiroToolWrapper `json:"tools,omitempty"`
ToolResults []KiroToolResult `json:"toolResults,omitempty"`
}
type KiroToolWrapper struct {
ToolSpecification struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema InputSchema `json:"inputSchema"`
} `json:"toolSpecification"`
}
type InputSchema struct {
JSON interface{} `json:"json"`
}
type KiroToolResult struct {
ToolUseID string `json:"toolUseId"`
Content []KiroResultContent `json:"content"`
Status string `json:"status"`
}
type KiroResultContent struct {
Text string `json:"text"`
}
type KiroImage struct {
Format string `json:"format"`
Source struct {
Bytes string `json:"bytes"`
} `json:"source"`
}
type KiroHistoryMessage struct {
UserInputMessage *KiroUserInputMessage `json:"userInputMessage,omitempty"`
AssistantResponseMessage *KiroAssistantResponseMessage `json:"assistantResponseMessage,omitempty"`
}
type KiroAssistantResponseMessage struct {
Content string `json:"content"`
ToolUses []KiroToolUse `json:"toolUses,omitempty"`
}
type KiroToolUse struct {
ToolUseID string `json:"toolUseId"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
}
type InferenceConfig struct {
MaxTokens int `json:"maxTokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"topP,omitempty"`
}
// ==================== 流式回调 ====================
// KiroStreamCallback 流式响应回调
type KiroStreamCallback struct {
OnText func(text string, isThinking bool)
OnToolUse func(toolUse KiroToolUse)
OnComplete func(inputTokens, outputTokens int)
OnError func(err error)
OnCredits func(credits float64)
}
// ==================== API 调用 ====================
// CallKiroAPI 调用 Kiro API流式
func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroStreamCallback) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("POST", KiroEndpoint, bytes.NewReader(body))
if err != nil {
return err
}
// 设置请求头
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("X-Amz-Target", "AmazonCodeWhispererStreamingService.GenerateAssistantResponse")
// User-Agent 包含机器码
machineId := account.MachineId
var userAgent, amzUserAgent string
if machineId != "" {
userAgent = fmt.Sprintf("aws-sdk-js/1.0.18 ua/2.1 os/linux lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.18 m/E KiroIDE-%s-%s", KiroVersion, machineId)
amzUserAgent = fmt.Sprintf("aws-sdk-js/1.0.18 KiroIDE %s %s", KiroVersion, machineId)
} else {
userAgent = fmt.Sprintf("aws-sdk-js/1.0.18 ua/2.1 os/linux lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.18 m/E KiroIDE-%s", KiroVersion)
amzUserAgent = fmt.Sprintf("aws-sdk-js/1.0.18 KiroIDE %s", KiroVersion)
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("X-Amz-User-Agent", amzUserAgent)
req.Header.Set("x-amzn-kiro-agent-mode", "spec")
req.Header.Set("x-amzn-codewhisperer-optout", "true")
req.Header.Set("Amz-Sdk-Request", "attempt=1; max=3")
req.Header.Set("Amz-Sdk-Invocation-Id", uuid.New().String())
req.Header.Set("Authorization", "Bearer "+account.AccessToken)
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return parseEventStream(resp.Body, callback)
}
// ==================== Event Stream 解析 ====================
// parseEventStream 解析 AWS Event Stream 二进制格式
func parseEventStream(body io.Reader, callback *KiroStreamCallback) error {
reader := bufio.NewReader(body)
var inputTokens, outputTokens int
var totalOutputChars int
var totalCredits float64
var currentToolUse *toolUseState
for {
// Prelude: 12 bytes (total_len + headers_len + crc)
prelude := make([]byte, 12)
_, err := io.ReadFull(reader, prelude)
if err == io.EOF {
break
}
if err != nil {
return err
}
totalLength := int(prelude[0])<<24 | int(prelude[1])<<16 | int(prelude[2])<<8 | int(prelude[3])
headersLength := int(prelude[4])<<24 | int(prelude[5])<<16 | int(prelude[6])<<8 | int(prelude[7])
if totalLength < 16 {
continue
}
// 读取剩余部分
remaining := totalLength - 12
msgBuf := make([]byte, remaining)
_, err = io.ReadFull(reader, msgBuf)
if err != nil {
return err
}
if headersLength > len(msgBuf)-4 {
continue
}
eventType := extractEventType(msgBuf[0:headersLength])
payloadBytes := msgBuf[headersLength : len(msgBuf)-4]
if len(payloadBytes) == 0 {
continue
}
var event map[string]interface{}
if err := json.Unmarshal(payloadBytes, &event); err != nil {
continue
}
// 处理事件
switch eventType {
case "assistantResponseEvent":
if content, ok := event["content"].(string); ok && content != "" {
callback.OnText(content, false)
totalOutputChars += len(content)
}
case "reasoningContentEvent":
if text, ok := event["text"].(string); ok && text != "" {
callback.OnText(text, true)
totalOutputChars += len(text)
}
case "toolUseEvent":
currentToolUse = handleToolUseEvent(event, currentToolUse, callback)
case "messageMetadataEvent", "metadataEvent":
if tokenUsage, ok := event["tokenUsage"].(map[string]interface{}); ok {
if v, ok := tokenUsage["outputTokens"].(float64); ok {
outputTokens = int(v)
}
uncached, _ := tokenUsage["uncachedInputTokens"].(float64)
cacheRead, _ := tokenUsage["cacheReadInputTokens"].(float64)
cacheWrite, _ := tokenUsage["cacheWriteInputTokens"].(float64)
inputTokens = int(uncached + cacheRead + cacheWrite)
}
case "meteringEvent":
if usage, ok := event["usage"].(float64); ok {
totalCredits += usage
}
}
}
// 估算 token约 3 字符 = 1 token
if outputTokens == 0 && totalOutputChars > 0 {
outputTokens = max(1, totalOutputChars/3)
}
if callback.OnCredits != nil && totalCredits > 0 {
callback.OnCredits(totalCredits)
}
callback.OnComplete(inputTokens, outputTokens)
return nil
}
// ==================== Tool Use 处理 ====================
type toolUseState struct {
ToolUseID string
Name string
InputBuffer strings.Builder
}
func handleToolUseEvent(event map[string]interface{}, current *toolUseState, callback *KiroStreamCallback) *toolUseState {
toolUseID, _ := event["toolUseId"].(string)
name, _ := event["name"].(string)
isStop, _ := event["stop"].(bool)
if toolUseID != "" && name != "" {
if current == nil {
current = &toolUseState{ToolUseID: toolUseID, Name: name}
} else if current.ToolUseID != toolUseID {
finishToolUse(current, callback)
current = &toolUseState{ToolUseID: toolUseID, Name: name}
}
}
if current != nil {
if input, ok := event["input"].(string); ok {
current.InputBuffer.WriteString(input)
} else if inputObj, ok := event["input"].(map[string]interface{}); ok {
data, _ := json.Marshal(inputObj)
current.InputBuffer.Reset()
current.InputBuffer.Write(data)
}
}
if isStop && current != nil {
finishToolUse(current, callback)
return nil
}
return current
}
func finishToolUse(state *toolUseState, callback *KiroStreamCallback) {
var input map[string]interface{}
if state.InputBuffer.Len() > 0 {
json.Unmarshal([]byte(state.InputBuffer.String()), &input)
}
if input == nil {
input = make(map[string]interface{})
}
callback.OnToolUse(KiroToolUse{
ToolUseID: state.ToolUseID,
Name: state.Name,
Input: input,
})
}
// extractEventType 从 headers 中提取事件类型
func extractEventType(headers []byte) string {
offset := 0
for offset < len(headers) {
if offset >= len(headers) {
break
}
nameLen := int(headers[offset])
offset++
if offset+nameLen > len(headers) {
break
}
name := string(headers[offset : offset+nameLen])
offset += nameLen
if offset >= len(headers) {
break
}
valueType := headers[offset]
offset++
if valueType == 7 { // String
if offset+2 > len(headers) {
break
}
valueLen := int(headers[offset])<<8 | int(headers[offset+1])
offset += 2
if offset+valueLen > len(headers) {
break
}
value := string(headers[offset : offset+valueLen])
offset += valueLen
if name == ":event-type" {
return value
}
continue
}
// 跳过其他类型
skipSizes := map[byte]int{0: 0, 1: 0, 2: 1, 3: 2, 4: 4, 5: 8, 8: 8, 9: 16}
if valueType == 6 {
if offset+2 > len(headers) {
break
}
l := int(headers[offset])<<8 | int(headers[offset+1])
offset += 2 + l
} else if skip, ok := skipSizes[valueType]; ok {
offset += skip
} else {
break
}
}
return ""
}

271
proxy/kiro_api.go Normal file
View File

@@ -0,0 +1,271 @@
package proxy
import (
"encoding/json"
"fmt"
"io"
"kiro-api-proxy/config"
"net/http"
"strings"
"time"
)
const (
kiroRestAPIBase = "https://codewhisperer.us-east-1.amazonaws.com"
kiroVersion = "0.6.18"
)
// GetUsageLimits 获取账户使用量和订阅信息
func GetUsageLimits(account *config.Account) (*UsageLimitsResponse, error) {
url := fmt.Sprintf("%s/getUsageLimits?origin=AI_EDITOR&resourceType=AGENTIC_REQUEST&isEmailRequired=true", kiroRestAPIBase)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
setKiroHeaders(req, account)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result UsageLimitsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
// GetUserInfo 获取用户信息
func GetUserInfo(account *config.Account) (*UserInfoResponse, error) {
url := fmt.Sprintf("%s/GetUserInfo", kiroRestAPIBase)
payload := `{"origin":"KIRO_IDE"}`
req, err := http.NewRequest("POST", url, strings.NewReader(payload))
if err != nil {
return nil, err
}
setKiroHeaders(req, account)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result UserInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
// ListAvailableModels 获取可用模型列表
func ListAvailableModels(account *config.Account) ([]ModelInfo, error) {
url := fmt.Sprintf("%s/ListAvailableModels?origin=AI_EDITOR&maxResults=50", kiroRestAPIBase)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
setKiroHeaders(req, account)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Models []ModelInfo `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Models, nil
}
func setKiroHeaders(req *http.Request, account *config.Account) {
machineId := account.MachineId
var userAgent, amzUserAgent string
if machineId != "" {
userAgent = fmt.Sprintf("aws-sdk-js/1.0.18 ua/2.1 os/windows lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.18 m/E KiroIDE-%s-%s", kiroVersion, machineId)
amzUserAgent = fmt.Sprintf("aws-sdk-js/1.0.18 KiroIDE %s %s", kiroVersion, machineId)
} else {
userAgent = fmt.Sprintf("aws-sdk-js/1.0.18 ua/2.1 os/windows lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.18 m/E KiroIDE-%s", kiroVersion)
amzUserAgent = fmt.Sprintf("aws-sdk-js/1.0.18 KiroIDE-%s", kiroVersion)
}
req.Header.Set("Authorization", "Bearer "+account.AccessToken)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent)
req.Header.Set("x-amz-user-agent", amzUserAgent)
req.Header.Set("x-amzn-codewhisperer-optout", "true")
}
// RefreshAccountInfo 刷新账户信息(使用量、订阅等)
func RefreshAccountInfo(account *config.Account) (*config.AccountInfo, error) {
info := &config.AccountInfo{
LastRefresh: time.Now().Unix(),
}
// 获取使用量和订阅信息
usage, err := GetUsageLimits(account)
if err != nil {
return nil, fmt.Errorf("GetUsageLimits: %w", err)
}
// 解析用户信息
if usage.UserInfo != nil {
info.Email = usage.UserInfo.Email
info.UserId = usage.UserInfo.UserId
}
// 解析订阅信息
if usage.SubscriptionInfo != nil {
// 优先从 SubscriptionTitle 或 SubscriptionName 解析类型
titleOrName := usage.SubscriptionInfo.SubscriptionTitle
if titleOrName == "" {
titleOrName = usage.SubscriptionInfo.SubscriptionName
}
if titleOrName == "" {
titleOrName = usage.SubscriptionInfo.SubscriptionType
}
info.SubscriptionType = parseSubscriptionType(titleOrName)
info.SubscriptionTitle = usage.SubscriptionInfo.SubscriptionTitle
if info.SubscriptionTitle == "" {
info.SubscriptionTitle = usage.SubscriptionInfo.SubscriptionName
}
fmt.Printf("[RefreshAccountInfo] Subscription: type=%s, title=%s, name=%s, parsed=%s\n",
usage.SubscriptionInfo.SubscriptionType,
usage.SubscriptionInfo.SubscriptionTitle,
usage.SubscriptionInfo.SubscriptionName,
info.SubscriptionType)
}
// 解析使用量
if len(usage.UsageBreakdownList) > 0 {
breakdown := usage.UsageBreakdownList[0]
info.UsageCurrent = breakdown.CurrentUsage
info.UsageLimit = breakdown.UsageLimit
if info.UsageLimit > 0 {
info.UsagePercent = info.UsageCurrent / info.UsageLimit
}
}
// 解析重置日期
if usage.NextDateReset != "" {
if ts, err := usage.NextDateReset.Int64(); err == nil && ts > 0 {
info.NextResetDate = time.Unix(ts, 0).Format("2006-01-02")
} else if f, err := usage.NextDateReset.Float64(); err == nil && f > 0 {
info.NextResetDate = time.Unix(int64(f), 0).Format("2006-01-02")
}
}
return info, nil
}
func parseSubscriptionType(raw string) string {
upper := strings.ToUpper(raw)
if strings.Contains(upper, "PRO_PLUS") || strings.Contains(upper, "PROPLUS") {
return "PRO_PLUS"
}
if strings.Contains(upper, "POWER") {
return "POWER"
}
if strings.Contains(upper, "PRO") {
return "PRO"
}
return "FREE"
}
// 响应结构体
type UsageLimitsResponse struct {
UsageBreakdownList []UsageBreakdown `json:"usageBreakdownList"`
NextDateReset json.Number `json:"nextDateReset"`
SubscriptionInfo *SubscriptionInfo `json:"subscriptionInfo"`
UserInfo *UserInfo `json:"userInfo"`
}
type UsageBreakdown struct {
ResourceType string `json:"resourceType"`
CurrentUsage float64 `json:"currentUsage"`
UsageLimit float64 `json:"usageLimit"`
Currency string `json:"currency"`
Unit string `json:"unit"`
OverageRate float64 `json:"overageRate"`
FreeTrialInfo *FreeTrialInfo `json:"freeTrialInfo"`
Bonuses []BonusInfo `json:"bonuses"`
}
type FreeTrialInfo struct {
CurrentUsage float64 `json:"currentUsage"`
UsageLimit float64 `json:"usageLimit"`
FreeTrialStatus string `json:"freeTrialStatus"`
FreeTrialExpiry int64 `json:"freeTrialExpiry"`
}
type BonusInfo struct {
BonusCode string `json:"bonusCode"`
DisplayName string `json:"displayName"`
CurrentUsage float64 `json:"currentUsage"`
UsageLimit float64 `json:"usageLimit"`
ExpiresAt int64 `json:"expiresAt"`
Status string `json:"status"`
}
type SubscriptionInfo struct {
SubscriptionName string `json:"subscriptionName"`
SubscriptionTitle string `json:"subscriptionTitle"`
SubscriptionType string `json:"subscriptionType"`
Status string `json:"status"`
UpgradeCapability string `json:"upgradeCapability"`
}
type UserInfo struct {
Email string `json:"email"`
UserId string `json:"userId"`
}
type UserInfoResponse struct {
Email string `json:"email"`
UserId string `json:"userId"`
Idp string `json:"idp"`
Status string `json:"status"`
}
type ModelInfo struct {
ModelId string `json:"modelId"`
ModelName string `json:"modelName"`
Description string `json:"description"`
InputTypes []string `json:"supportedInputTypes"`
RateMultiplier float64 `json:"rateMultiplier"`
TokenLimits *struct {
MaxInputTokens int `json:"maxInputTokens"`
MaxOutputTokens int `json:"maxOutputTokens"`
} `json:"tokenLimits"`
}

811
proxy/translator.go Normal file
View File

@@ -0,0 +1,811 @@
package proxy
import (
"encoding/base64"
"encoding/json"
"regexp"
"strings"
"time"
"github.com/google/uuid"
)
// 模型映射
var modelMap = map[string]string{
"claude-sonnet-4-5": "claude-sonnet-4.5",
"claude-sonnet-4.5": "claude-sonnet-4.5",
"claude-haiku-4-5": "claude-haiku-4.5",
"claude-haiku-4.5": "claude-haiku-4.5",
"claude-opus-4-5": "claude-opus-4.5",
"claude-opus-4.5": "claude-opus-4.5",
"claude-sonnet-4": "claude-sonnet-4",
"claude-sonnet-4-20250514": "claude-sonnet-4",
"claude-3-5-sonnet": "claude-sonnet-4.5",
"claude-3-opus": "claude-sonnet-4.5",
"claude-3-sonnet": "claude-sonnet-4",
"claude-3-haiku": "claude-haiku-4.5",
"gpt-4": "claude-sonnet-4.5",
"gpt-4o": "claude-sonnet-4.5",
"gpt-4-turbo": "claude-sonnet-4.5",
"gpt-3.5-turbo": "claude-sonnet-4.5",
}
func MapModel(model string) string {
lower := strings.ToLower(model)
for k, v := range modelMap {
if strings.Contains(lower, k) {
return v
}
}
// 如果已经是有效的 Kiro 模型,直接返回
if strings.HasPrefix(lower, "claude-") {
return model
}
return "claude-sonnet-4.5"
}
// ==================== Claude API 类型 ====================
type ClaudeRequest struct {
Model string `json:"model"`
Messages []ClaudeMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
System interface{} `json:"system,omitempty"` // string or []SystemBlock
Tools []ClaudeTool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
}
type ClaudeMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"` // string or []ContentBlock
}
type ClaudeContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input interface{} `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content interface{} `json:"content,omitempty"` // for tool_result
Source *ImageSource `json:"source,omitempty"`
}
type ImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
type ClaudeTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema interface{} `json:"input_schema"`
}
type ClaudeResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []ClaudeContentBlock `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence"`
Usage ClaudeUsage `json:"usage"`
}
type ClaudeUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// ==================== Claude -> Kiro 转换 ====================
const maxToolDescLen = 10237
func ClaudeToKiro(req *ClaudeRequest) *KiroPayload {
modelID := MapModel(req.Model)
origin := "AI_EDITOR"
// 提取系统提示
systemPrompt := extractSystemPrompt(req.System)
// 注入时间戳
timestamp := time.Now().Format(time.RFC3339)
systemPrompt = "[Context: Current time is " + timestamp + "]\n\n" + systemPrompt
// 注入执行导向指令(防止 AI 在探索过程中丢失目标)
executionDirective := `
<execution_discipline>
当用户要求执行特定任务时,你必须遵循以下纪律:
1. **目标锁定**:在整个会话中始终牢记用户的原始目标,不要在代码探索过程中迷失方向
2. **行动优先**:优先执行任务而非仅分析或总结,除非用户明确只要求分析
3. **计划执行**:为任务创建明确的步骤计划,逐步执行并标记完成状态
4. **禁止确认性收尾**:在任务未完成前,禁止输出"需要我继续吗?"、"需要深入分析吗?"等确认性问题
5. **持续推进**:如果发现部分任务已完成,立即继续执行剩余未完成的任务
6. **完整交付**:直到所有任务步骤都执行完毕才算完成
</execution_discipline>
`
systemPrompt = systemPrompt + "\n\n" + executionDirective
// 构建历史消息
history := make([]KiroHistoryMessage, 0)
var currentContent string
var currentImages []KiroImage
var currentToolResults []KiroToolResult
for i, msg := range req.Messages {
isLast := i == len(req.Messages)-1
if msg.Role == "user" {
content, images, toolResults := extractClaudeUserContent(msg.Content)
if isLast {
currentContent = content
currentImages = images
currentToolResults = toolResults
} else {
userMsg := KiroUserInputMessage{
Content: content,
ModelID: modelID,
Origin: origin,
}
if len(images) > 0 {
userMsg.Images = images
}
if len(toolResults) > 0 {
userMsg.UserInputMessageContext = &UserInputMessageContext{
ToolResults: toolResults,
}
}
history = append(history, KiroHistoryMessage{
UserInputMessage: &userMsg,
})
}
} else if msg.Role == "assistant" {
content, toolUses := extractClaudeAssistantContent(msg.Content)
history = append(history, KiroHistoryMessage{
AssistantResponseMessage: &KiroAssistantResponseMessage{
Content: content,
ToolUses: toolUses,
},
})
}
}
// 确保 history 以 user 开始
if len(history) > 0 && history[0].AssistantResponseMessage != nil {
history = append([]KiroHistoryMessage{{
UserInputMessage: &KiroUserInputMessage{
Content: "Begin conversation",
ModelID: modelID,
Origin: origin,
},
}}, history...)
}
// 构建最终内容
finalContent := ""
if systemPrompt != "" {
finalContent = "--- SYSTEM PROMPT ---\n" + systemPrompt + "\n--- END SYSTEM PROMPT ---\n\n"
}
if currentContent != "" {
finalContent += currentContent
} else if len(currentToolResults) > 0 {
finalContent += "Tool results provided."
} else {
finalContent += "Continue"
}
// 转换工具
kiroTools := convertClaudeTools(req.Tools)
// 构建 payload
payload := &KiroPayload{}
payload.ConversationState.ChatTriggerType = "MANUAL"
payload.ConversationState.ConversationID = uuid.New().String()
payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{
Content: finalContent,
ModelID: modelID,
Origin: origin,
Images: currentImages,
}
if len(kiroTools) > 0 || len(currentToolResults) > 0 {
payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext = &UserInputMessageContext{
Tools: kiroTools,
ToolResults: currentToolResults,
}
}
if len(history) > 0 {
payload.ConversationState.History = history
}
if req.MaxTokens > 0 || req.Temperature > 0 || req.TopP > 0 {
payload.InferenceConfig = &InferenceConfig{
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
}
}
return payload
}
func extractSystemPrompt(system interface{}) string {
if system == nil {
return ""
}
if s, ok := system.(string); ok {
return s
}
if blocks, ok := system.([]interface{}); ok {
var parts []string
for _, b := range blocks {
if block, ok := b.(map[string]interface{}); ok {
if text, ok := block["text"].(string); ok {
parts = append(parts, text)
}
}
}
return strings.Join(parts, "\n")
}
return ""
}
func extractClaudeUserContent(content interface{}) (string, []KiroImage, []KiroToolResult) {
var text string
var images []KiroImage
var toolResults []KiroToolResult
if s, ok := content.(string); ok {
return s, nil, nil
}
if blocks, ok := content.([]interface{}); ok {
for _, b := range blocks {
block, ok := b.(map[string]interface{})
if !ok {
continue
}
blockType, _ := block["type"].(string)
switch blockType {
case "text":
if t, ok := block["text"].(string); ok {
text += t
}
case "image":
if source, ok := block["source"].(map[string]interface{}); ok {
mediaType, _ := source["media_type"].(string)
data, _ := source["data"].(string)
format := strings.TrimPrefix(mediaType, "image/")
if format == "jpg" {
format = "jpeg"
}
images = append(images, KiroImage{
Format: format,
Source: struct {
Bytes string `json:"bytes"`
}{Bytes: data},
})
}
case "tool_result":
toolUseID, _ := block["tool_use_id"].(string)
resultContent := extractToolResultContent(block["content"])
toolResults = append(toolResults, KiroToolResult{
ToolUseID: toolUseID,
Content: []KiroResultContent{{Text: resultContent}},
Status: "success",
})
}
}
}
return text, images, toolResults
}
func extractToolResultContent(content interface{}) string {
if s, ok := content.(string); ok {
return s
}
if blocks, ok := content.([]interface{}); ok {
var parts []string
for _, b := range blocks {
if block, ok := b.(map[string]interface{}); ok {
if text, ok := block["text"].(string); ok {
parts = append(parts, text)
}
}
}
return strings.Join(parts, "")
}
return ""
}
func extractClaudeAssistantContent(content interface{}) (string, []KiroToolUse) {
var text string
var toolUses []KiroToolUse
if s, ok := content.(string); ok {
return s, nil
}
if blocks, ok := content.([]interface{}); ok {
for _, b := range blocks {
block, ok := b.(map[string]interface{})
if !ok {
continue
}
blockType, _ := block["type"].(string)
switch blockType {
case "text":
if t, ok := block["text"].(string); ok {
text += t
}
case "tool_use":
id, _ := block["id"].(string)
name, _ := block["name"].(string)
input, _ := block["input"].(map[string]interface{})
if input == nil {
input = make(map[string]interface{})
}
toolUses = append(toolUses, KiroToolUse{
ToolUseID: id,
Name: name,
Input: input,
})
}
}
}
if text == "" && len(toolUses) > 0 {
text = "Using tools."
}
return text, toolUses
}
func convertClaudeTools(tools []ClaudeTool) []KiroToolWrapper {
if len(tools) == 0 {
return nil
}
result := make([]KiroToolWrapper, len(tools))
for i, tool := range tools {
desc := tool.Description
if len(desc) > maxToolDescLen {
desc = desc[:maxToolDescLen] + "..."
}
result[i] = KiroToolWrapper{}
result[i].ToolSpecification.Name = shortenToolName(tool.Name)
result[i].ToolSpecification.Description = desc
result[i].ToolSpecification.InputSchema = InputSchema{JSON: tool.InputSchema}
}
return result
}
func shortenToolName(name string) string {
if len(name) <= 64 {
return name
}
// MCP tools: mcp__server__tool -> mcp__tool
if strings.HasPrefix(name, "mcp__") {
lastIdx := strings.LastIndex(name, "__")
if lastIdx > 5 {
shortened := "mcp__" + name[lastIdx+2:]
if len(shortened) <= 64 {
return shortened
}
}
}
return name[:64]
}
// ==================== Kiro -> Claude 转换 ====================
func KiroToClaudeResponse(content string, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *ClaudeResponse {
blocks := make([]ClaudeContentBlock, 0)
if content != "" {
blocks = append(blocks, ClaudeContentBlock{
Type: "text",
Text: content,
})
}
for _, tu := range toolUses {
blocks = append(blocks, ClaudeContentBlock{
Type: "tool_use",
ID: tu.ToolUseID,
Name: tu.Name,
Input: tu.Input,
})
}
stopReason := "end_turn"
if len(toolUses) > 0 {
stopReason = "tool_use"
}
return &ClaudeResponse{
ID: "msg_" + uuid.New().String(),
Type: "message",
Role: "assistant",
Content: blocks,
Model: model,
StopReason: stopReason,
Usage: ClaudeUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
},
}
}
// ==================== OpenAI API 类型 ====================
type OpenAIRequest struct {
Model string `json:"model"`
Messages []OpenAIMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []OpenAITool `json:"tools,omitempty"`
}
type OpenAIMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type OpenAITool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
} `json:"function"`
}
type OpenAIResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []OpenAIChoice `json:"choices"`
Usage OpenAIUsage `json:"usage"`
}
type OpenAIChoice struct {
Index int `json:"index"`
Message OpenAIMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type OpenAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ==================== OpenAI -> Kiro 转换 ====================
func OpenAIToKiro(req *OpenAIRequest) *KiroPayload {
modelID := MapModel(req.Model)
origin := "AI_EDITOR"
// 提取系统提示
var systemPrompt string
var nonSystemMessages []OpenAIMessage
for _, msg := range req.Messages {
if msg.Role == "system" {
if s, ok := msg.Content.(string); ok {
systemPrompt += s + "\n"
}
} else {
nonSystemMessages = append(nonSystemMessages, msg)
}
}
// 注入时间戳
timestamp := time.Now().Format(time.RFC3339)
systemPrompt = "[Context: Current time is " + timestamp + "]\n\n" + systemPrompt
// 注入执行导向指令(防止 AI 在探索过程中丢失目标)
executionDirective := `
<execution_discipline>
当用户要求执行特定任务时,你必须遵循以下纪律:
1. **目标锁定**:在整个会话中始终牢记用户的原始目标,不要在代码探索过程中迷失方向
2. **行动优先**:优先执行任务而非仅分析或总结,除非用户明确只要求分析
3. **计划执行**:为任务创建明确的步骤计划,逐步执行并标记完成状态
4. **禁止确认性收尾**:在任务未完成前,禁止输出"需要我继续吗?"、"需要深入分析吗?"等确认性问题
5. **持续推进**:如果发现部分任务已完成,立即继续执行剩余未完成的任务
6. **完整交付**:直到所有任务步骤都执行完毕才算完成
</execution_discipline>
`
systemPrompt = systemPrompt + "\n\n" + executionDirective
// 构建历史消息
history := make([]KiroHistoryMessage, 0)
var currentContent string
var currentImages []KiroImage
var currentToolResults []KiroToolResult
systemMerged := false
for i, msg := range nonSystemMessages {
isLast := i == len(nonSystemMessages)-1
switch msg.Role {
case "user":
content, images := extractOpenAIUserContent(msg.Content)
// 第一条 user 消息合并 system prompt
if !systemMerged && systemPrompt != "" {
content = systemPrompt + "\n" + content
systemMerged = true
}
if isLast {
currentContent = content
currentImages = images
} else {
history = append(history, KiroHistoryMessage{
UserInputMessage: &KiroUserInputMessage{
Content: content,
ModelID: modelID,
Origin: origin,
Images: images,
},
})
}
case "assistant":
content, _ := msg.Content.(string)
if content == "" && len(msg.ToolCalls) > 0 {
content = "Using tools."
}
var toolUses []KiroToolUse
for _, tc := range msg.ToolCalls {
var input map[string]interface{}
json.Unmarshal([]byte(tc.Function.Arguments), &input)
if input == nil {
input = make(map[string]interface{})
}
toolUses = append(toolUses, KiroToolUse{
ToolUseID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
history = append(history, KiroHistoryMessage{
AssistantResponseMessage: &KiroAssistantResponseMessage{
Content: content,
ToolUses: toolUses,
},
})
case "tool":
content, _ := msg.Content.(string)
currentToolResults = append(currentToolResults, KiroToolResult{
ToolUseID: msg.ToolCallID,
Content: []KiroResultContent{{Text: content}},
Status: "success",
})
// 检查下一条是否还是 tool
nextIdx := i + 1
if nextIdx >= len(nonSystemMessages) || nonSystemMessages[nextIdx].Role != "tool" {
if !isLast {
history = append(history, KiroHistoryMessage{
UserInputMessage: &KiroUserInputMessage{
Content: "Tool results provided.",
ModelID: modelID,
Origin: origin,
UserInputMessageContext: &UserInputMessageContext{
ToolResults: currentToolResults,
},
},
})
currentToolResults = nil
}
}
}
}
// 构建最终内容
finalContent := currentContent
if finalContent == "" {
if len(currentToolResults) > 0 {
finalContent = "Tool results provided."
} else {
finalContent = "Continue"
}
}
if !systemMerged && systemPrompt != "" {
finalContent = systemPrompt + "\n" + finalContent
}
// 转换工具
kiroTools := convertOpenAITools(req.Tools)
// 构建 payload
payload := &KiroPayload{}
payload.ConversationState.ChatTriggerType = "MANUAL"
payload.ConversationState.ConversationID = uuid.New().String()
payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{
Content: finalContent,
ModelID: modelID,
Origin: origin,
Images: currentImages,
}
if len(kiroTools) > 0 || len(currentToolResults) > 0 {
payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext = &UserInputMessageContext{
Tools: kiroTools,
ToolResults: currentToolResults,
}
}
if len(history) > 0 {
payload.ConversationState.History = history
}
if req.MaxTokens > 0 || req.Temperature > 0 || req.TopP > 0 {
payload.InferenceConfig = &InferenceConfig{
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
}
}
return payload
}
func extractOpenAIUserContent(content interface{}) (string, []KiroImage) {
if s, ok := content.(string); ok {
return s, nil
}
var text string
var images []KiroImage
if parts, ok := content.([]interface{}); ok {
for _, p := range parts {
part, ok := p.(map[string]interface{})
if !ok {
continue
}
partType, _ := part["type"].(string)
switch partType {
case "text":
if t, ok := part["text"].(string); ok {
text += t
}
case "image_url":
if imgUrl, ok := part["image_url"].(map[string]interface{}); ok {
if url, ok := imgUrl["url"].(string); ok {
if img := parseDataURL(url); img != nil {
images = append(images, *img)
}
}
}
}
}
}
return text, images
}
func parseDataURL(url string) *KiroImage {
// data:image/png;base64,xxxxx
re := regexp.MustCompile(`^data:image/(\w+);base64,(.+)$`)
matches := re.FindStringSubmatch(url)
if len(matches) != 3 {
return nil
}
format := matches[1]
if format == "jpg" {
format = "jpeg"
}
// 验证 base64
if _, err := base64.StdEncoding.DecodeString(matches[2]); err != nil {
return nil
}
return &KiroImage{
Format: format,
Source: struct {
Bytes string `json:"bytes"`
}{Bytes: matches[2]},
}
}
func convertOpenAITools(tools []OpenAITool) []KiroToolWrapper {
if len(tools) == 0 {
return nil
}
result := make([]KiroToolWrapper, 0, len(tools))
for _, tool := range tools {
if tool.Type != "function" {
continue
}
desc := tool.Function.Description
if len(desc) > maxToolDescLen {
desc = desc[:maxToolDescLen] + "..."
}
wrapper := KiroToolWrapper{}
wrapper.ToolSpecification.Name = shortenToolName(tool.Function.Name)
wrapper.ToolSpecification.Description = desc
wrapper.ToolSpecification.InputSchema = InputSchema{JSON: tool.Function.Parameters}
result = append(result, wrapper)
}
return result
}
// ==================== Kiro -> OpenAI 转换 ====================
func KiroToOpenAIResponse(content string, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *OpenAIResponse {
msg := OpenAIMessage{
Role: "assistant",
}
finishReason := "stop"
if len(toolUses) > 0 {
msg.Content = nil
msg.ToolCalls = make([]ToolCall, len(toolUses))
for i, tu := range toolUses {
args, _ := json.Marshal(tu.Input)
msg.ToolCalls[i] = ToolCall{
ID: tu.ToolUseID,
Type: "function",
}
msg.ToolCalls[i].Function.Name = tu.Name
msg.ToolCalls[i].Function.Arguments = string(args)
}
finishReason = "tool_calls"
} else {
msg.Content = content
}
return &OpenAIResponse{
ID: "chatcmpl-" + uuid.New().String(),
Object: "chat.completion",
Created: time.Now().Unix(),
Model: model,
Choices: []OpenAIChoice{{
Index: 0,
Message: msg,
FinishReason: finishReason,
}},
Usage: OpenAIUsage{
PromptTokens: inputTokens,
CompletionTokens: outputTokens,
TotalTokens: inputTokens + outputTokens,
},
}
}

616
web/index.html Normal file
View File

@@ -0,0 +1,616 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kiro API Proxy</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f8fafc; color: #1e293b; min-height: 100vh; }
.container { max-width: 1100px; margin: 0 auto; padding: 20px; }
.login-wrapper { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%); }
.login-box { background: #fff; padding: 40px; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); width: 100%; max-width: 380px; }
.login-box h1 { text-align: center; color: #7c3aed; margin-bottom: 8px; }
.login-box p { text-align: center; color: #64748b; margin-bottom: 24px; font-size: 14px; }
.form-group { margin-bottom: 16px; }
label { display: block; margin-bottom: 6px; color: #374151; font-size: 14px; font-weight: 500; }
input[type="text"], input[type="password"], select, textarea { width: 100%; padding: 10px 14px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; background: #fff; color: #1e293b; }
input:focus, textarea:focus { outline: none; border-color: #7c3aed; box-shadow: 0 0 0 3px rgba(124,58,237,0.1); }
textarea { resize: vertical; min-height: 80px; font-family: monospace; }
.btn { padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; }
.btn-primary { background: #7c3aed; color: white; }
.btn-primary:hover { background: #6d28d9; }
.btn-danger { background: #ef4444; color: white; }
.btn-secondary { background: #f1f5f9; color: #374151; }
.btn-sm { padding: 6px 12px; font-size: 13px; }
.card { background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #e2e8f0; }
.card-title { font-size: 16px; font-weight: 600; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 20px; }
.stat-card { background: #fff; border-radius: 10px; padding: 16px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-value { font-size: 24px; font-weight: 700; color: #7c3aed; }
.stat-label { font-size: 12px; color: #64748b; margin-top: 4px; }
.badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
.badge-error { background: #fee2e2; color: #dc2626; }
.badge-info { background: #ede9fe; color: #7c3aed; }
.badge-pro { background: #3b82f6; color: white; }
.badge-proplus { background: #8b5cf6; color: white; }
.badge-power { background: #f59e0b; color: white; }
.badge-free { background: #6b7280; color: white; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 1000; }
.modal.active { display: flex; align-items: center; justify-content: center; }
.modal-content { background: #fff; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-title { font-size: 18px; font-weight: 600; }
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #64748b; }
.modal-footer { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end; }
.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: #cbd5e1; border-radius: 24px; transition: 0.3s; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.3s; }
input:checked + .slider { background: #7c3aed; }
input:checked + .slider:before { transform: translateX(20px); }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; background: #f1f5f9; padding: 4px; border-radius: 10px; }
.tab { padding: 10px 18px; border-radius: 8px; cursor: pointer; color: #64748b; font-weight: 500; font-size: 14px; }
.tab:hover { color: #374151; }
.tab.active { background: #fff; color: #7c3aed; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.hidden { display: none !important; }
.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 12px; font-size: 14px; }
.message-error { background: #fee2e2; color: #dc2626; }
.endpoint { background: #f8fafc; padding: 12px 16px; border-radius: 8px; font-family: monospace; font-size: 13px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.header h1 { font-size: 20px; color: #7c3aed; }
.account-card { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-left: 4px solid #7c3aed; }
.account-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.account-email { font-weight: 600; font-size: 15px; color: #1e293b; }
.account-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.account-usage { margin: 12px 0; }
.usage-bar { height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; }
.usage-fill { height: 100%; background: #7c3aed; border-radius: 4px; transition: width 0.3s; }
.usage-fill.high { background: #f59e0b; }
.usage-fill.critical { background: #ef4444; }
.usage-text { display: flex; justify-content: space-between; font-size: 12px; color: #64748b; margin-top: 4px; }
.account-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0; }
.account-stat { text-align: center; }
.account-stat-value { font-weight: 600; font-size: 14px; }
.account-stat-label { font-size: 11px; color: #64748b; }
.account-actions { display: flex; gap: 6px; }
.detail-section { margin-bottom: 20px; }
.detail-section h4 { font-size: 14px; color: #64748b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.detail-item { background: #f8fafc; padding: 12px; border-radius: 8px; }
.detail-label { font-size: 12px; color: #64748b; margin-bottom: 4px; }
.detail-value { font-size: 14px; font-weight: 500; }
.model-list { display: grid; gap: 8px; max-height: 300px; overflow-y: auto; }
.model-item { background: #f8fafc; padding: 10px 12px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; }
.model-name { font-weight: 500; font-size: 13px; }
.model-info { font-size: 11px; color: #64748b; }
.btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; }
.btn-icon svg { width: 16px; height: 16px; }
.icon { width: 16px; height: 16px; vertical-align: middle; }
.loading { opacity: 0.6; pointer-events: none; }
.logo { display: flex; align-items: center; gap: 8px; }
.logo svg { width: 24px; height: 24px; color: #7c3aed; }
</style>
</head>
<body>
<div id="loginPage" class="login-wrapper">
<div class="login-box">
<h1 class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>Kiro API Proxy</h1>
<p>请输入管理密码登录</p>
<div class="form-group">
<label>管理密码</label>
<input type="password" id="pwdField" placeholder="输入密码" autocomplete="off">
</div>
<button type="button" class="btn btn-primary" style="width:100%" onclick="login()">登录</button>
<div id="loginError" class="message message-error hidden" style="margin-top:12px"></div>
</div>
</div>
<div id="mainPage" class="container hidden">
<div class="header">
<h1 class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>Kiro API Proxy</h1>
<span class="badge badge-success" id="statusBadge">运行中</span>
</div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-value" id="statAccounts">0</div><div class="stat-label">账号</div></div>
<div class="stat-card"><div class="stat-value" id="statRequests">0</div><div class="stat-label">请求</div></div>
<div class="stat-card"><div class="stat-value" id="statSuccess">0</div><div class="stat-label">成功</div></div>
<div class="stat-card"><div class="stat-value" id="statFailed">0</div><div class="stat-label">失败</div></div>
<div class="stat-card"><div class="stat-value" id="statTokens">0</div><div class="stat-label">Tokens</div></div>
<div class="stat-card"><div class="stat-value" id="statCredits">0</div><div class="stat-label">Credits</div></div>
</div>
<div class="tabs">
<div class="tab active" data-tab="accounts">账号管理</div>
<div class="tab" data-tab="settings">设置</div>
<div class="tab" data-tab="api">API 端点</div>
</div>
<div id="tabAccounts" class="tab-content">
<div class="card">
<div class="card-header">
<span class="card-title">账号列表</span>
<div>
<button class="btn btn-secondary btn-sm" onclick="showModal('credentials')">导入凭证</button>
<button class="btn btn-secondary btn-sm" onclick="showModal('sso')">SSO Token</button>
<button class="btn btn-primary btn-sm" onclick="showModal('iam')">IAM SSO</button>
</div>
</div>
<div id="accountsList"></div>
</div>
</div>
<div id="tabSettings" class="tab-content hidden">
<div class="card">
<div class="card-header"><span class="card-title">API 设置</span></div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:10px">
<label class="switch"><input type="checkbox" id="requireApiKey"><span class="slider"></span></label>
启用 API Key 验证
</label>
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" id="apiKeyInput" placeholder="留空则不验证">
</div>
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
</div>
<div class="card">
<div class="card-header"><span class="card-title">管理密码</span></div>
<div class="form-group">
<label>新密码</label>
<input type="password" id="newPassword" placeholder="输入新密码">
</div>
<button class="btn btn-primary" onclick="changePassword()">修改密码</button>
</div>
<div class="card">
<div class="card-header"><span class="card-title">统计</span></div>
<button class="btn btn-danger" onclick="resetStats()">重置统计</button>
</div>
</div>
<div id="tabApi" class="tab-content hidden">
<div class="card">
<div class="card-header"><span class="card-title">API 端点</span></div>
<p style="margin-bottom:12px;font-weight:500">Claude API</p>
<div class="endpoint"><span id="claudeEndpoint"></span><button class="btn btn-sm btn-secondary" onclick="copy('claudeEndpoint')">复制</button></div>
<p style="margin:16px 0 12px;font-weight:500">OpenAI API</p>
<div class="endpoint"><span id="openaiEndpoint"></span><button class="btn btn-sm btn-secondary" onclick="copy('openaiEndpoint')">复制</button></div>
<p style="margin:16px 0 12px;font-weight:500">模型列表</p>
<div class="endpoint"><span id="modelsEndpoint"></span><button class="btn btn-sm btn-secondary" onclick="copy('modelsEndpoint')">复制</button></div>
</div>
</div>
</div>
<div id="addModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<span class="modal-title" id="modalTitle">添加账号</span>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div id="modalBody"></div>
</div>
</div>
<div id="detailModal" class="modal">
<div class="modal-content" style="max-width:700px">
<div class="modal-header">
<span class="modal-title">账号详情</span>
<button class="modal-close" onclick="closeDetailModal()">&times;</button>
</div>
<div id="detailBody"></div>
</div>
</div>
<script>
let password = localStorage.getItem('admin_password') || '';
const baseUrl = location.origin;
let accountsData = [];
document.addEventListener('DOMContentLoaded', function() {
if (password) tryAutoLogin();
document.getElementById('pwdField').addEventListener('keypress', e => { if (e.key === 'Enter') login(); });
document.querySelectorAll('.tab').forEach(tab => { tab.onclick = () => switchTab(tab.dataset.tab); });
});
async function tryAutoLogin() {
try {
const res = await fetch('/admin/api/status', { headers: { 'X-Admin-Password': password } });
if (res.ok) { showMain(); loadData(); }
} catch (e) {}
}
async function login() {
password = document.getElementById('pwdField').value;
try {
const res = await fetch('/admin/api/status', { headers: { 'X-Admin-Password': password } });
if (res.ok) {
localStorage.setItem('admin_password', password);
showMain(); loadData();
} else {
document.getElementById('loginError').textContent = '密码错误';
document.getElementById('loginError').classList.remove('hidden');
}
} catch (e) {
document.getElementById('loginError').textContent = '连接失败';
document.getElementById('loginError').classList.remove('hidden');
}
}
function showMain() {
document.getElementById('loginPage').classList.add('hidden');
document.getElementById('mainPage').classList.remove('hidden');
}
async function loadData() {
await Promise.all([loadStats(), loadAccounts(), loadSettings()]);
document.getElementById('claudeEndpoint').textContent = baseUrl + '/v1/messages';
document.getElementById('openaiEndpoint').textContent = baseUrl + '/v1/chat/completions';
document.getElementById('modelsEndpoint').textContent = baseUrl + '/v1/models';
}
async function loadStats() {
const res = await fetch('/admin/api/status', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
document.getElementById('statAccounts').textContent = d.accounts || 0;
document.getElementById('statRequests').textContent = d.totalRequests || 0;
document.getElementById('statSuccess').textContent = d.successRequests || 0;
document.getElementById('statFailed').textContent = d.failedRequests || 0;
document.getElementById('statTokens').textContent = formatNum(d.totalTokens || 0);
document.getElementById('statCredits').textContent = (d.totalCredits || 0).toFixed(2);
}
async function loadAccounts() {
const res = await fetch('/admin/api/accounts', { headers: { 'X-Admin-Password': password } });
accountsData = await res.json();
renderAccounts();
}
function renderAccounts() {
const container = document.getElementById('accountsList');
if (accountsData.length === 0) {
container.innerHTML = '<p style="text-align:center;color:#64748b;padding:40px">暂无账号,请添加</p>';
return;
}
container.innerHTML = accountsData.map(a => {
const usagePercent = (a.usagePercent || 0) * 100;
const usageClass = usagePercent > 90 ? 'critical' : usagePercent > 70 ? 'high' : '';
return `<div class="account-card">
<div class="account-header">
<div>
<div class="account-email">${a.email || a.id.substring(0,12)+'...'}</div>
<div class="account-meta">
${getSubBadge(a.subscriptionType)}
<span class="badge badge-info">${a.authMethod || '-'}</span>
${getStatusBadge(a)}
</div>
</div>
<div class="account-actions">
<button class="btn btn-sm btn-icon btn-secondary" onclick="refreshAccount('${a.id}')" title="刷新信息"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg></button>
<button class="btn btn-sm btn-icon btn-secondary" onclick="showDetail('${a.id}')" title="详情"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg></button>
<button class="btn btn-sm ${a.enabled ? 'btn-secondary' : 'btn-primary'}" onclick="toggleAccount('${a.id}',${!a.enabled})">${a.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-sm btn-danger" onclick="deleteAccount('${a.id}')">删除</button>
</div>
</div>
${a.usageLimit > 0 ? `<div class="account-usage">
<div class="usage-bar"><div class="usage-fill ${usageClass}" style="width:${usagePercent}%"></div></div>
<div class="usage-text">
<span>${a.usageCurrent?.toFixed(1) || 0} / ${a.usageLimit?.toFixed(0) || 0}</span>
<span>${usagePercent.toFixed(1)}%${a.nextResetDate ? ' · 重置: '+a.nextResetDate : ''}</span>
</div>
</div>` : ''}
<div class="account-stats">
<div class="account-stat"><div class="account-stat-value">${a.requestCount || 0}</div><div class="account-stat-label">请求</div></div>
<div class="account-stat"><div class="account-stat-value">${formatNum(a.totalTokens || 0)}</div><div class="account-stat-label">Tokens</div></div>
<div class="account-stat"><div class="account-stat-value">${(a.totalCredits || 0).toFixed(2)}</div><div class="account-stat-label">Credits</div></div>
<div class="account-stat"><div class="account-stat-value">${formatTokenExpiry(a.expiresAt)}</div><div class="account-stat-label">Token到期</div></div>
</div>
</div>`;
}).join('');
}
function getSubBadge(type) {
const t = (type || '').toUpperCase();
if (t.includes('POWER')) return '<span class="badge badge-power">POWER</span>';
if (t.includes('PRO_PLUS') || t.includes('PROPLUS')) return '<span class="badge badge-proplus">PRO+</span>';
if (t.includes('PRO')) return '<span class="badge badge-pro">PRO</span>';
return '<span class="badge badge-free">FREE</span>';
}
function getStatusBadge(a) {
if (!a.hasToken) return '<span class="badge badge-error">无Token</span>';
if (a.expiresAt && a.expiresAt < Date.now()/1000) return '<span class="badge badge-warning">已过期</span>';
if (!a.enabled) return '<span class="badge badge-warning">已禁用</span>';
return '<span class="badge badge-success">正常</span>';
}
function formatTokenExpiry(ts) {
if (!ts) return '-';
const diff = ts - Date.now()/1000;
if (diff <= 0) return '已过期';
if (diff < 3600) return Math.floor(diff/60) + '分钟';
if (diff < 86400) return Math.floor(diff/3600) + '时';
return Math.floor(diff/86400) + '天';
}
async function refreshAccount(id) {
const card = event.target.closest('.account-card');
if (card) card.classList.add('loading');
try {
const res = await fetch('/admin/api/accounts/' + id + '/refresh', { method: 'POST', headers: { 'X-Admin-Password': password } });
const d = await res.json();
if (d.success) { loadAccounts(); } else { alert('刷新失败: ' + d.error); }
} catch (e) { alert('刷新失败'); }
if (card) card.classList.remove('loading');
}
async function showDetail(id) {
const a = accountsData.find(x => x.id === id);
if (!a) return;
const body = document.getElementById('detailBody');
body.innerHTML = `
<div class="detail-section">
<h4>基本信息</h4>
<div class="detail-grid">
<div class="detail-item"><div class="detail-label">邮箱</div><div class="detail-value">${a.email || '-'}</div></div>
<div class="detail-item"><div class="detail-label">用户ID</div><div class="detail-value" style="font-size:12px;word-break:break-all">${a.userId || '-'}</div></div>
<div class="detail-item"><div class="detail-label">认证方式</div><div class="detail-value">${a.authMethod || '-'}</div></div>
<div class="detail-item"><div class="detail-label">Region</div><div class="detail-value">${a.region || 'us-east-1'}</div></div>
</div>
</div>
<div class="detail-section">
<h4>机器码</h4>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="machineIdInput" value="${a.machineId || ''}" style="flex:1;font-family:monospace;font-size:12px" placeholder="UUID 格式: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<button class="btn btn-sm btn-secondary" onclick="generateMachineId()">生成</button>
<button class="btn btn-sm btn-primary" onclick="saveMachineId('${id}')">保存</button>
</div>
</div>
<div class="detail-section">
<h4>订阅信息</h4>
<div class="detail-grid">
<div class="detail-item"><div class="detail-label">订阅类型</div><div class="detail-value">${a.subscriptionTitle || a.subscriptionType || '-'}</div></div>
<div class="detail-item"><div class="detail-label">剩余天数</div><div class="detail-value">${a.daysRemaining || '-'}</div></div>
<div class="detail-item"><div class="detail-label">Token到期</div><div class="detail-value">${a.expiresAt ? new Date(a.expiresAt*1000).toLocaleString() : '-'}</div></div>
<div class="detail-item"><div class="detail-label">上次刷新</div><div class="detail-value">${a.lastRefresh ? new Date(a.lastRefresh*1000).toLocaleString() : '-'}</div></div>
</div>
</div>
<div class="detail-section">
<h4>使用量</h4>
<div class="detail-grid">
<div class="detail-item"><div class="detail-label">当前用量</div><div class="detail-value">${a.usageCurrent?.toFixed(2) || 0}</div></div>
<div class="detail-item"><div class="detail-label">用量上限</div><div class="detail-value">${a.usageLimit?.toFixed(0) || 0}</div></div>
<div class="detail-item"><div class="detail-label">使用比例</div><div class="detail-value">${((a.usagePercent||0)*100).toFixed(1)}%</div></div>
<div class="detail-item"><div class="detail-label">重置日期</div><div class="detail-value">${a.nextResetDate || '-'}</div></div>
</div>
</div>
<div class="detail-section">
<h4>统计</h4>
<div class="detail-grid">
<div class="detail-item"><div class="detail-label">请求数</div><div class="detail-value">${a.requestCount || 0}</div></div>
<div class="detail-item"><div class="detail-label">错误数</div><div class="detail-value">${a.errorCount || 0}</div></div>
<div class="detail-item"><div class="detail-label">总Tokens</div><div class="detail-value">${formatNum(a.totalTokens || 0)}</div></div>
<div class="detail-item"><div class="detail-label">总Credits</div><div class="detail-value">${(a.totalCredits || 0).toFixed(2)}</div></div>
</div>
</div>
<div class="detail-section">
<h4>可用模型 <button class="btn btn-sm btn-secondary" onclick="loadModels('${id}')" style="margin-left:10px">加载</button></h4>
<div id="modelsList" class="model-list"><p style="color:#64748b;font-size:13px">点击加载按钮获取模型列表</p></div>
</div>
`;
document.getElementById('detailModal').classList.add('active');
}
async function loadModels(id) {
const container = document.getElementById('modelsList');
container.innerHTML = '<p style="color:#64748b">加载中...</p>';
try {
const res = await fetch('/admin/api/accounts/' + id + '/models', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
if (d.success && d.models) {
container.innerHTML = d.models.map(m => `<div class="model-item">
<div><div class="model-name">${m.modelId}</div><div class="model-info">${m.description || ''}</div></div>
<div class="model-info">${m.tokenLimits ? (m.tokenLimits.maxInputTokens/1000)+'K / '+(m.tokenLimits.maxOutputTokens/1000)+'K' : ''}</div>
</div>`).join('') || '<p style="color:#64748b">无可用模型</p>';
} else {
container.innerHTML = '<p style="color:#ef4444">加载失败: ' + (d.error || '未知错误') + '</p>';
}
} catch (e) { container.innerHTML = '<p style="color:#ef4444">加载失败</p>'; }
}
function closeDetailModal() { document.getElementById('detailModal').classList.remove('active'); }
async function generateMachineId() {
try {
const res = await fetch('/admin/api/generate-machine-id', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
if (d.machineId) {
document.getElementById('machineIdInput').value = d.machineId;
}
} catch (e) { alert('生成失败'); }
}
async function saveMachineId(id) {
const machineId = document.getElementById('machineIdInput').value.trim();
// UUID 格式或 32位十六进制
if (machineId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(machineId) && !/^[0-9a-f]{32}$/i.test(machineId)) {
alert('机器码格式错误,需要 UUID 格式 (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) 或 32位十六进制');
return;
}
try {
const res = await fetch('/admin/api/accounts/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ machineId })
});
const d = await res.json();
if (d.success) {
alert('已保存');
loadAccounts();
} else {
alert('保存失败: ' + d.error);
}
} catch (e) { alert('保存失败'); }
}
async function loadSettings() {
const res = await fetch('/admin/api/settings', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
document.getElementById('requireApiKey').checked = d.requireApiKey;
document.getElementById('apiKeyInput').value = d.apiKey || '';
}
async function saveSettings() {
await fetch('/admin/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ requireApiKey: document.getElementById('requireApiKey').checked, apiKey: document.getElementById('apiKeyInput').value })
});
alert('已保存');
}
async function changePassword() {
const newPwd = document.getElementById('newPassword').value;
if (!newPwd) return alert('请输入新密码');
await fetch('/admin/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ password: newPwd })
});
password = newPwd;
localStorage.setItem('admin_password', password);
alert('密码已修改');
document.getElementById('newPassword').value = '';
}
async function resetStats() {
if (!confirm('确定重置统计?')) return;
await fetch('/admin/api/stats/reset', { method: 'POST', headers: { 'X-Admin-Password': password } });
loadStats();
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.remove('hidden');
}
async function toggleAccount(id, enabled) {
await fetch('/admin/api/accounts/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ enabled })
});
loadAccounts();
}
async function deleteAccount(id) {
if (!confirm('确定删除?')) return;
await fetch('/admin/api/accounts/' + id, { method: 'DELETE', headers: { 'X-Admin-Password': password } });
loadAccounts(); loadStats();
}
function formatNum(n) {
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
if (n >= 1000) return (n/1000).toFixed(1) + 'K';
return n.toString();
}
function copy(id) {
navigator.clipboard.writeText(document.getElementById(id).textContent);
alert('已复制');
}
function showModal(type) {
const modal = document.getElementById('addModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
if (type === 'credentials') {
title.textContent = '导入凭证';
body.innerHTML = `
<div class="form-group"><label>凭证 JSON</label><textarea id="credJson" placeholder='{"accessToken":"...","refreshToken":"...","clientId":"...","clientSecret":"..."}'></textarea></div>
<div class="form-group"><label>认证方式</label><select id="credAuth"><option value="social">Social</option><option value="idc">IAM IdC</option></select></div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="importCredentials()">导入</button></div>`;
} else if (type === 'sso') {
title.textContent = 'SSO Token';
body.innerHTML = `
<div class="form-group"><label>Bearer Token</label><textarea id="ssoToken" placeholder="从浏览器获取的 Bearer Token"></textarea></div>
<div class="form-group"><label>Region</label><input type="text" id="ssoRegion" value="us-east-1"></div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="importSsoToken()">导入</button></div>`;
} else if (type === 'iam') {
title.textContent = 'IAM SSO 登录';
body.innerHTML = `
<div class="form-group"><label>Start URL</label><input type="text" id="iamStartUrl" placeholder="https://xxx.awsapps.com/start"></div>
<div class="form-group"><label>Region</label><input type="text" id="iamRegion" value="us-east-1"></div>
<div id="iamStep2" class="hidden">
<p style="color:#16a34a;margin:12px 0">请在浏览器中完成登录,然后粘贴回调 URL</p>
<div class="form-group"><label>回调 URL</label><input type="text" id="iamCallback" placeholder="http://127.0.0.1:xxx/?code=..."></div>
</div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal()">取消</button><button class="btn btn-primary" id="iamBtn" onclick="startIamSso()">开始登录</button></div>`;
}
modal.classList.add('active');
}
function closeModal() { document.getElementById('addModal').classList.remove('active'); }
async function importCredentials() {
try {
const json = JSON.parse(document.getElementById('credJson').value);
json.authMethod = document.getElementById('credAuth').value;
const res = await fetch('/admin/api/auth/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify(json)
});
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert('导入成功: ' + (d.account?.email || d.account?.id)); }
else alert('失败: ' + d.error);
} catch (e) { alert('JSON 格式错误'); }
}
async function importSsoToken() {
const res = await fetch('/admin/api/auth/sso-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ bearerToken: document.getElementById('ssoToken').value, region: document.getElementById('ssoRegion').value })
});
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert('导入成功: ' + (d.account?.email || d.account?.id)); }
else alert('失败: ' + d.error);
}
let iamSession = '';
async function startIamSso() {
if (iamSession) {
const res = await fetch('/admin/api/auth/iam-sso/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ sessionId: iamSession, callbackUrl: document.getElementById('iamCallback').value })
});
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert('登录成功: ' + (d.account?.email || d.account?.id)); iamSession = ''; }
else alert('失败: ' + d.error);
} else {
const res = await fetch('/admin/api/auth/iam-sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ startUrl: document.getElementById('iamStartUrl').value, region: document.getElementById('iamRegion').value })
});
const d = await res.json();
if (d.authorizeUrl) {
iamSession = d.sessionId;
window.open(d.authorizeUrl, '_blank');
document.getElementById('iamStep2').classList.remove('hidden');
document.getElementById('iamBtn').textContent = '完成登录';
} else alert('失败: ' + d.error);
}
}
setInterval(() => { if (!document.getElementById('mainPage').classList.contains('hidden')) loadStats(); }, 10000);
</script>
</body>
</html>