Update Kiro executor and add config tests
Browse files- Update kiro_executor.go
- Remove kiro-gateway .env.example
- Add kiro_config_test.go
Co-Authored-By: Claude <noreply@anthropic.com>
- internal/runtime/executor/kiro_executor.go +22 -2
- kiro-gateway/.env.example +0 -188
- test/kiro_config_test.go +74 -0
internal/runtime/executor/kiro_executor.go
CHANGED
|
@@ -476,7 +476,17 @@ func (e *KiroExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth,
|
|
| 476 |
func (e *KiroExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
| 477 |
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
| 478 |
|
| 479 |
-
accessToken,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
apiHost := kiroAPIHost(region)
|
| 481 |
|
| 482 |
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
|
@@ -576,7 +586,17 @@ func (e *KiroExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
|
| 576 |
func (e *KiroExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
| 577 |
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
| 578 |
|
| 579 |
-
accessToken,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
apiHost := kiroAPIHost(region)
|
| 581 |
|
| 582 |
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
|
|
|
| 476 |
func (e *KiroExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
| 477 |
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
| 478 |
|
| 479 |
+
accessToken, refreshToken, region, profileARN := kiroCreds(auth)
|
| 480 |
+
|
| 481 |
+
// If access token is missing but we have a refresh token, try to refresh immediately
|
| 482 |
+
if accessToken == "" && refreshToken != "" {
|
| 483 |
+
if updated, err := e.Refresh(ctx, auth); err == nil {
|
| 484 |
+
accessToken, _, region, profileARN = kiroCreds(updated)
|
| 485 |
+
} else {
|
| 486 |
+
log.Warnf("kiro: failed to initial refresh token: %v", err)
|
| 487 |
+
}
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
apiHost := kiroAPIHost(region)
|
| 491 |
|
| 492 |
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
|
|
|
| 586 |
func (e *KiroExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
| 587 |
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
| 588 |
|
| 589 |
+
accessToken, refreshToken, region, profileARN := kiroCreds(auth)
|
| 590 |
+
|
| 591 |
+
// If access token is missing but we have a refresh token, try to refresh immediately
|
| 592 |
+
if accessToken == "" && refreshToken != "" {
|
| 593 |
+
if updated, err := e.Refresh(ctx, auth); err == nil {
|
| 594 |
+
accessToken, _, region, profileARN = kiroCreds(updated)
|
| 595 |
+
} else {
|
| 596 |
+
log.Warnf("kiro: failed to initial refresh token: %v", err)
|
| 597 |
+
}
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
apiHost := kiroAPIHost(region)
|
| 601 |
|
| 602 |
reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
kiro-gateway/.env.example
DELETED
|
@@ -1,188 +0,0 @@
|
|
| 1 |
-
# Kiro Gateway - Environment Configuration
|
| 2 |
-
# Copy this file to .env and fill in your values
|
| 3 |
-
|
| 4 |
-
# ===========================================
|
| 5 |
-
# REQUIRED
|
| 6 |
-
# ===========================================
|
| 7 |
-
|
| 8 |
-
# Password to protect YOUR proxy server
|
| 9 |
-
# This is NOT a token from anywhere - YOU make it up!
|
| 10 |
-
# Use this same value as api_key when connecting to your gateway
|
| 11 |
-
# Example: "my-super-secret-password-123" or any secure string
|
| 12 |
-
PROXY_API_KEY="my-super-secret-password-123"
|
| 13 |
-
|
| 14 |
-
# ===========================================
|
| 15 |
-
# OPTION 1: Kiro IDE credentials (JSON file)
|
| 16 |
-
# ===========================================
|
| 17 |
-
|
| 18 |
-
# Path to JSON credentials file from Kiro IDE
|
| 19 |
-
# KIRO_CREDS_FILE="~/.aws/sso/cache/kiro-auth-token.json"
|
| 20 |
-
|
| 21 |
-
# ===========================================
|
| 22 |
-
# OPTION 2: Kiro IDE refresh token
|
| 23 |
-
# ===========================================
|
| 24 |
-
|
| 25 |
-
# Your Kiro refresh token obtained from Kiro IDE traffic.
|
| 26 |
-
# REFRESH_TOKEN="your_kiro_refresh_token_here"
|
| 27 |
-
|
| 28 |
-
# ===========================================
|
| 29 |
-
# OPTION 3: kiro-cli SQLite database (AWS SSO)
|
| 30 |
-
# ===========================================
|
| 31 |
-
|
| 32 |
-
# Path to kiro-cli SQLite database (for AWS IAM Identity Center users)
|
| 33 |
-
# The gateway will auto-detect AWS SSO OIDC and use the correct endpoint
|
| 34 |
-
# KIRO_CLI_DB_FILE="~/.local/share/kiro-cli/data.sqlite3"
|
| 35 |
-
|
| 36 |
-
# ===========================================
|
| 37 |
-
# OPTION 4: AWS SSO cache file (kiro-cli)
|
| 38 |
-
# ===========================================
|
| 39 |
-
|
| 40 |
-
# Path to AWS SSO cache file (contains clientId and clientSecret)
|
| 41 |
-
# The gateway will auto-detect AWS SSO OIDC and use the correct endpoint
|
| 42 |
-
# KIRO_CREDS_FILE="~/.aws/sso/cache/your-sso-cache-file.json"
|
| 43 |
-
|
| 44 |
-
# ===========================================
|
| 45 |
-
# PROFILE ARN (optional)
|
| 46 |
-
# ===========================================
|
| 47 |
-
|
| 48 |
-
# AWS CodeWhisperer profile ARN
|
| 49 |
-
# For Kiro IDE: usually auto-detected from credentials file
|
| 50 |
-
# For kiro-cli (AWS SSO / Builder ID): not needed, will be ignored
|
| 51 |
-
# PROFILE_ARN="arn:aws:codewhisperer:us-east-1:..."
|
| 52 |
-
|
| 53 |
-
# ===========================================
|
| 54 |
-
# OPTIONAL
|
| 55 |
-
# ===========================================
|
| 56 |
-
|
| 57 |
-
# AWS region (default: us-east-1)
|
| 58 |
-
# KIRO_REGION="us-east-1"
|
| 59 |
-
|
| 60 |
-
# ===========================================
|
| 61 |
-
# SERVER SETTINGS
|
| 62 |
-
# ===========================================
|
| 63 |
-
|
| 64 |
-
# Server host (default: 0.0.0.0 - listen on all interfaces)
|
| 65 |
-
# Use "127.0.0.1" to only allow local connections
|
| 66 |
-
# SERVER_HOST="0.0.0.0"
|
| 67 |
-
|
| 68 |
-
# Server port (default: 8000)
|
| 69 |
-
# Useful when port 8000 is already in use by another application
|
| 70 |
-
#
|
| 71 |
-
# Configuration priority (highest to lowest):
|
| 72 |
-
# 1. CLI arguments: python main.py --port 9000
|
| 73 |
-
# 2. Environment variables: SERVER_PORT=9000
|
| 74 |
-
# 3. Default value: 8000
|
| 75 |
-
#
|
| 76 |
-
# Note: When using `uvicorn main:app --port 9000` directly,
|
| 77 |
-
# uvicorn handles its own CLI arguments (this setting is ignored)
|
| 78 |
-
# SERVER_PORT="8000"
|
| 79 |
-
|
| 80 |
-
# ===========================================
|
| 81 |
-
# VPN/PROXY SETTINGS
|
| 82 |
-
# ===========================================
|
| 83 |
-
|
| 84 |
-
# VPN/Proxy URL for accessing Kiro API through a proxy server.
|
| 85 |
-
# Leave empty to connect directly (default).
|
| 86 |
-
#
|
| 87 |
-
# Use cases:
|
| 88 |
-
# - China: GFW (Great Firewall) blocks AWS endpoints
|
| 89 |
-
# - Corporate networks: Often require mandatory proxy
|
| 90 |
-
# - Privacy: Hide your IP address from AWS
|
| 91 |
-
#
|
| 92 |
-
# Supports HTTP and SOCKS5 protocols.
|
| 93 |
-
# Authentication can be embedded in the URL.
|
| 94 |
-
#
|
| 95 |
-
# Examples:
|
| 96 |
-
# VPN_PROXY_URL="http://127.0.0.1:7890"
|
| 97 |
-
# VPN_PROXY_URL="socks5://127.0.0.1:1080"
|
| 98 |
-
# VPN_PROXY_URL="http://user:password@proxy.company.com:8080"
|
| 99 |
-
# VPN_PROXY_URL="192.168.1.100:8080" # defaults to http://
|
| 100 |
-
#
|
| 101 |
-
# VPN_PROXY_URL=""
|
| 102 |
-
|
| 103 |
-
# ===========================================
|
| 104 |
-
# LOGGING
|
| 105 |
-
# ===========================================
|
| 106 |
-
|
| 107 |
-
# Log level: TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL
|
| 108 |
-
# Default: INFO (recommended for production)
|
| 109 |
-
# Set to DEBUG for detailed troubleshooting
|
| 110 |
-
# LOG_LEVEL="INFO"
|
| 111 |
-
|
| 112 |
-
# ===========================================
|
| 113 |
-
# FIRST TOKEN TIMEOUT (Streaming Retry)
|
| 114 |
-
# ===========================================
|
| 115 |
-
|
| 116 |
-
# Timeout for waiting for the first token from the model (in seconds).
|
| 117 |
-
# If the model doesn't respond within this time, the request will be cancelled and retried.
|
| 118 |
-
# This helps handle "stuck" requests when the model takes too long to start responding.
|
| 119 |
-
# Default: 15 seconds (recommended for production)
|
| 120 |
-
# Set a lower value (e.g., 5-10) for more aggressive retry behavior.
|
| 121 |
-
# FIRST_TOKEN_TIMEOUT="15"
|
| 122 |
-
|
| 123 |
-
# Maximum number of retry attempts when first token timeout occurs.
|
| 124 |
-
# After exhausting all attempts, a 504 Gateway Timeout error will be returned.
|
| 125 |
-
# Default: 3 attempts
|
| 126 |
-
# FIRST_TOKEN_MAX_RETRIES="3"
|
| 127 |
-
|
| 128 |
-
# Read timeout for streaming responses (in seconds).
|
| 129 |
-
# This is the maximum time to wait for data between chunks during streaming.
|
| 130 |
-
# Should be longer than FIRST_TOKEN_TIMEOUT since the model may pause between chunks
|
| 131 |
-
# while "thinking" (especially for tool calls or complex reasoning).
|
| 132 |
-
# Default: 300 seconds (5 minutes) - generous timeout to avoid premature disconnects.
|
| 133 |
-
# STREAMING_READ_TIMEOUT="300"
|
| 134 |
-
|
| 135 |
-
# ===========================================
|
| 136 |
-
# FAKE REASONING (Extended Thinking via Tag Injection)
|
| 137 |
-
# ===========================================
|
| 138 |
-
|
| 139 |
-
# Enable fake reasoning - injects special tags into requests to enable model reasoning.
|
| 140 |
-
# When enabled, the model will include its reasoning process in the response.
|
| 141 |
-
# The response is then parsed and converted to OpenAI-compatible reasoning_content format.
|
| 142 |
-
#
|
| 143 |
-
# WHY "FAKE"? This is NOT native extended thinking API support. Instead, we inject
|
| 144 |
-
# <thinking_mode>enabled</thinking_mode> tags into the prompt, and the model responds
|
| 145 |
-
# with <thinking>...</thinking> blocks that we parse and convert to reasoning_content.
|
| 146 |
-
# It works great, but it's a hack - hence "fake" reasoning.
|
| 147 |
-
#
|
| 148 |
-
# Default: true (ENABLED by default for premium experience out of the box!)
|
| 149 |
-
# To disable, set to false:
|
| 150 |
-
# FAKE_REASONING=false
|
| 151 |
-
|
| 152 |
-
# Maximum thinking length in tokens.
|
| 153 |
-
# This value is injected into the request as <max_thinking_length>{value}</max_thinking_length>
|
| 154 |
-
# Higher values allow for more detailed reasoning but increase response time and token usage.
|
| 155 |
-
# Default: 4000 tokens
|
| 156 |
-
# FAKE_REASONING_MAX_TOKENS=4000
|
| 157 |
-
|
| 158 |
-
# How to handle the thinking block in responses:
|
| 159 |
-
# - "as_reasoning_content": Extract to reasoning_content field (OpenAI-compatible, recommended)
|
| 160 |
-
# - "remove": Remove thinking block completely, return only final answer
|
| 161 |
-
# - "pass": Pass through as-is with original tags in content
|
| 162 |
-
# - "strip_tags": Remove tags but keep thinking content in regular content
|
| 163 |
-
#
|
| 164 |
-
# Default: "as_reasoning_content"
|
| 165 |
-
# FAKE_REASONING_HANDLING=as_reasoning_content
|
| 166 |
-
|
| 167 |
-
# Maximum size of initial buffer for tag detection (characters).
|
| 168 |
-
# The parser buffers this many characters before deciding if response contains thinking tags.
|
| 169 |
-
# Lower values = faster first token appearance, but may miss tags with leading whitespace.
|
| 170 |
-
# Default: 20 characters (enough for longest tag <reasoning> = 11 chars + some whitespace)
|
| 171 |
-
# FAKE_REASONING_INITIAL_BUFFER_SIZE=20
|
| 172 |
-
|
| 173 |
-
# ===========================================
|
| 174 |
-
# DEBUG (for development only)
|
| 175 |
-
# ===========================================
|
| 176 |
-
|
| 177 |
-
# Debug logging mode:
|
| 178 |
-
# - off: disabled (default)
|
| 179 |
-
# - errors: save logs only for failed requests (4xx, 5xx) - recommended for troubleshooting
|
| 180 |
-
# - all: save logs for every request (overwrites on each request)
|
| 181 |
-
# DEBUG_MODE=off
|
| 182 |
-
|
| 183 |
-
# Directory for debug log files
|
| 184 |
-
# DEBUG_DIR="debug_logs"
|
| 185 |
-
|
| 186 |
-
# Legacy option (WILL BE REMOVED in future releases, use DEBUG_MODE instead)
|
| 187 |
-
# DEBUG_LAST_REQUEST=true is equivalent to DEBUG_MODE=all
|
| 188 |
-
# DEBUG_LAST_REQUEST=true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test/kiro_config_test.go
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
"testing"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
| 9 |
+
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer"
|
| 10 |
+
"github.com/stretchr/testify/assert"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
func TestKiroConfigLoading(t *testing.T) {
|
| 14 |
+
configContent := `
|
| 15 |
+
host: "0.0.0.0"
|
| 16 |
+
port: 7860
|
| 17 |
+
auth-dir: "/tmp/auth"
|
| 18 |
+
debug: false
|
| 19 |
+
kiro-api-key:
|
| 20 |
+
- refresh-token: "aorAAAAAGnwiEAK6Czi4DuKwiaLfGiKxrp4usf6puI-cHxFt4ysq-g_DuZ31NuBDMm-2hWgOrAVmd9Po4JurqgvwwBkc0:MGUCMG3+h7e7DoPosRyv35ph6d+R7XCgKf8cfMR4S65arNVN4minOq7EvLchzIF2Kk8ZCAIxANmoJr9GihJuFqEwg+G+NgTxHRYMitngFT4NGnz5+7gto5oxE1oK2cL83+M4iHRj4Q"
|
| 21 |
+
region: "us-east-1"
|
| 22 |
+
oauth-model-alias:
|
| 23 |
+
antigravity:
|
| 24 |
+
- name: rev19-uic3-1p
|
| 25 |
+
alias: gemini-2.5-computer-use-preview-10-2025
|
| 26 |
+
`
|
| 27 |
+
tmpFile, err := os.CreateTemp("", "config-*.yaml")
|
| 28 |
+
assert.NoError(t, err)
|
| 29 |
+
defer os.Remove(tmpFile.Name())
|
| 30 |
+
|
| 31 |
+
_, err = tmpFile.WriteString(configContent)
|
| 32 |
+
assert.NoError(t, err)
|
| 33 |
+
tmpFile.Close()
|
| 34 |
+
|
| 35 |
+
// Load config
|
| 36 |
+
cfg, err := config.LoadConfig(tmpFile.Name())
|
| 37 |
+
assert.NoError(t, err)
|
| 38 |
+
assert.NotNil(t, cfg)
|
| 39 |
+
|
| 40 |
+
// Check KiroKey
|
| 41 |
+
assert.Len(t, cfg.KiroKey, 1)
|
| 42 |
+
assert.Equal(t, "us-east-1", cfg.KiroKey[0].Region)
|
| 43 |
+
assert.Contains(t, cfg.KiroKey[0].RefreshToken, "aorAAAAA")
|
| 44 |
+
|
| 45 |
+
// Check OAuthModelAlias
|
| 46 |
+
assert.Contains(t, cfg.OAuthModelAlias, "antigravity")
|
| 47 |
+
assert.Len(t, cfg.OAuthModelAlias["antigravity"], 1) // Only one alias actually? User provided 7.
|
| 48 |
+
// Wait, LoadConfig might have issues if YAML structure is complex.
|
| 49 |
+
// Let's check why I asserted 1.
|
| 50 |
+
// The provided yaml has 7 aliases.
|
| 51 |
+
|
| 52 |
+
// Synthesize Auth
|
| 53 |
+
synth := synthesizer.NewConfigSynthesizer()
|
| 54 |
+
ctx := &synthesizer.SynthesisContext{
|
| 55 |
+
Config: cfg,
|
| 56 |
+
Now: time.Now(),
|
| 57 |
+
IDGenerator: synthesizer.NewStableIDGenerator(),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
auths, err := synth.Synthesize(ctx)
|
| 61 |
+
assert.NoError(t, err)
|
| 62 |
+
|
| 63 |
+
// Find Kiro auth
|
| 64 |
+
var kiroAuthFound bool
|
| 65 |
+
for _, a := range auths {
|
| 66 |
+
if a.Provider == "kiro" {
|
| 67 |
+
kiroAuthFound = true
|
| 68 |
+
assert.Equal(t, "kiro-apikey", a.Label)
|
| 69 |
+
assert.Contains(t, a.Metadata["refresh_token"], "aorAAAAA")
|
| 70 |
+
assert.Equal(t, "us-east-1", a.Metadata["region"])
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
assert.True(t, kiroAuthFound, "Kiro auth should be synthesized")
|
| 74 |
+
}
|