Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Full reference: `.env.template` and `config/config.yaml`
- `USER_PATH_HEADER` (`X-GoModel-User-Path`: Header used to read/write request `user_path` values)
- `ENABLE_PASSTHROUGH_ROUTES` (true: Enable provider-native passthrough routes under /p/{provider}/...)
- `ALLOW_PASSTHROUGH_V1_ALIAS` (true: Allow /p/{provider}/v1/... aliases while keeping /p/{provider}/... canonical)
- `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,zai,vllm: Comma-separated list of enabled passthrough providers)
- `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,zai,vllm,deepseek,bailian,kimi: Comma-separated list of enabled passthrough providers)
- `REALTIME_ENABLED` (true: Expose the realtime speech-to-speech websocket at `/v1/realtime` and the `/p/{provider}/v1/realtime` upgrade. The canonical `/v1/realtime` route needs only `REALTIME_ENABLED`; the `/p/{provider}/v1/realtime` upgrade additionally requires passthrough routes enabled (`ENABLE_PASSTHROUGH_ROUTES`) with the provider listed in `ENABLED_PASSTHROUGH_PROVIDERS`. The gateway is a transparent websocket reverse proxy — it injects provider credentials and relays the provider's realtime event schema verbatim (no translation), so clients connect without provider API keys. Only providers implementing realtime accept sessions. Currently: OpenAI and xAI/Grok Voice Agent (both `wss://…/v1/realtime`); Z.ai/Zhipu GLM-Realtime (`wss://…/api/paas/v4/realtime`); Bailian/Qwen-Omni (`wss://dashscope…/api-ws/v1/realtime`); and Azure OpenAI (`wss://<resource>/openai/realtime?api-version=…&deployment=…`, `api-key` header). All use OpenAI's realtime event schema (Z.ai adds extensions that relay transparently). Provider-specific notes: xAI voice models (e.g. `grok-voice-latest`) aren't in upstream `/models` discovery, so configure them via `XAI_MODELS`, and xAI bills realtime per-minute (no token usage reported); Azure realtime requires a realtime-capable `AZURE_API_VERSION` (the default may be too old) and the model selects the Azure deployment. (MiniMax was evaluated but skipped — its conversational realtime schema is not OpenAI-compatible.) Sessions are gated by the same model-access and budget rules as other model endpoints; usage is tracked per `response.done` event, accepting both the OpenAI singular and Alibaba plural token-detail spellings.)
- **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (data/gomodel.db), `POSTGRES_URL`, `MONGODB_URL`
- **Models:** `MODELS_ENABLED_BY_DEFAULT` (true), `KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT` (false), `CONFIGURED_PROVIDER_MODELS_MODE` (`fallback` or `allowlist`, default `fallback`; `allowlist` skips upstream `/models` for providers with configured lists); persisted overrides restrict/allow selectors with `user_paths`. When alias-only models listing is enabled, `GET /v1/models` returns only model aliases, not full concrete model specs, to operators.
Expand All @@ -131,5 +131,5 @@ Full reference: `.env.template` and `config/config.yaml`
- **Resilience:** Configured via `config/config.yaml` - global `resilience.retry.*` and `resilience.circuit_breaker.*` defaults with optional per-provider overrides under `providers.<name>.resilience.retry.*` and `providers.<name>.resilience.circuit_breaker.*`. Retry defaults: `max_retries` (3), `initial_backoff` (1s), `max_backoff` (30s), `backoff_factor` (2.0), `jitter_factor` (0.1). Circuit breaker defaults: `failure_threshold` (5), `success_threshold` (2), `timeout` (30s). Breaker state is per-process, not shown in the dashboard, and exported as the `gomodel_circuit_breaker_state` gauge when metrics are enabled.
- **Metrics:** `METRICS_ENABLED` (false), `METRICS_ENDPOINT` (/metrics)
- **Guardrails:** Definitions are persisted in the `guardrail_definitions` store and managed via the admin API/dashboard; `config/config.yaml` entries are validated and upserted into that store at startup (a seed, not the source of truth). `GUARDRAILS_ENABLED` env var gates the feature.
- **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `<PROVIDER>[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token)
- **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `KIMI_API_KEY`, `KIMI_BASE_URL` (optional Kimi endpoint override; default `https://api.kimi.com/coding/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `<PROVIDER>[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token)
- **Provider model metadata:** `providers.<name>.models` accepts either model IDs (strings) or `{id, metadata}` objects. When `metadata` is supplied (`display_name`, `context_window`, `max_output_tokens`, `modes`, `capabilities`, `pricing`, …) it is merged onto the remote ai-model-list entry during enrichment, with operator values winning per-field. Primary use case: advertising context windows, capabilities, and pricing for local models (Ollama) and other custom endpoints whose IDs are not in the upstream registry.
51 changes: 44 additions & 7 deletions cmd/recordapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@ import (
)

const oracleDefaultModel = "openai.gpt-oss-120b"
const kimiDefaultModel = "kimi-for-coding"

// Provider configurations
var providerConfigs = map[string]struct {
baseURL string
baseURLEnv string
envKey string
authHeader string
contentType string
baseURL string
baseURLEnv string
envKey string
authHeader string
contentType string
customHeaders map[string]string
}{
"openai": {
baseURL: "https://api.openai.com",
Expand Down Expand Up @@ -61,6 +63,23 @@ var providerConfigs = map[string]struct {
authHeader: "Authorization",
contentType: "application/json",
},
"kimi": {
baseURL: "https://api.kimi.com/coding",
envKey: "KIMI_API_KEY",
authHeader: "Authorization",
contentType: "application/json",
customHeaders: map[string]string{
"User-Agent": "KimiCLI/0.76 (linux; x86_64)",
"X-Title": "Kimi CLI",
"Http-Referer": "https://www.kimi.com",
"Accept": "application/json, text/event-stream",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Mode": "cors",
"X-Stainless-Lang": "go",
"X-Stainless-Package-Version": "0.1.0",
},
},
"oracle": {
baseURLEnv: "ORACLE_BASE_URL",
envKey: "ORACLE_API_KEY",
Expand Down Expand Up @@ -119,6 +138,14 @@ var endpointConfigs = map[string]struct {
"stream": true,
},
},
"embeddings": {
path: "/v1/embeddings",
method: http.MethodPost,
requestBody: map[string]any{
"model": "text-embedding-3-small",
"input": "hello world",
},
},
}

var providerCapabilities = map[string]map[string]bool{
Expand All @@ -137,6 +164,9 @@ var providerCapabilities = map[string]map[string]bool{
"xai": {
"responses": true,
},
"kimi": {
"responses": false,
},
"oracle": {
"responses": true,
},
Expand All @@ -155,8 +185,8 @@ func providerSupportsResponses(provider string) bool {
}

func main() {
provider := flag.String("provider", "openai", "Provider to test (openai, anthropic, gemini, groq, xai, oracle)")
endpoint := flag.String("endpoint", "chat", "Endpoint to test (chat, chat_stream, models, responses, responses_stream)")
provider := flag.String("provider", "openai", "Provider to test (openai, anthropic, gemini, groq, xai, kimi, oracle)")
endpoint := flag.String("endpoint", "chat", "Endpoint to test (chat, chat_stream, models, responses, responses_stream, embeddings)")
output := flag.String("output", "", "Output file path (required)")
model := flag.String("model", "", "Override model in request")
flag.Parse()
Expand Down Expand Up @@ -209,6 +239,8 @@ func main() {
reqBody["model"] = *model
} else if *provider == "oracle" {
reqBody["model"] = oracleDefaultModel
} else if *provider == "kimi" {
reqBody["model"] = kimiDefaultModel
}

// Adjust request for different providers
Expand Down Expand Up @@ -250,6 +282,11 @@ func main() {
req.Header.Set("anthropic-version", "2023-06-01")
}

// Add provider-specific custom headers (e.g., Kimi ZooCode headers)
for key, value := range pConfig.customHeaders {
req.Header.Set(key, value)
}

// Send request
client := &http.Client{Timeout: 60 * time.Second}
fmt.Printf("Sending request to %s %s...\n", eConfig.method, url)
Expand Down
80 changes: 80 additions & 0 deletions cmd/recordapi/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package main

import (
"net/http"
"testing"
)

// TestKimiProviderConfig verifies the Kimi provider entry has the expected
// base URL, env key, auth header, and required custom headers.
func TestKimiProviderConfig(t *testing.T) {
cfg, ok := providerConfigs["kimi"]
if !ok {
t.Fatalf("providerConfigs[\"kimi\"] missing")
}
if cfg.baseURL != "https://api.kimi.com/coding" {
t.Errorf("baseURL = %q, want %q", cfg.baseURL, "https://api.kimi.com/coding")
}
if cfg.envKey != "KIMI_API_KEY" {
t.Errorf("envKey = %q, want %q", cfg.envKey, "KIMI_API_KEY")
}
if cfg.authHeader != "Authorization" {
t.Errorf("authHeader = %q, want %q", cfg.authHeader, "Authorization")
}
if cfg.customHeaders == nil {
t.Fatalf("customHeaders is nil, want non-nil map")
}
for _, key := range []string{"User-Agent", "X-Title", "Http-Referer"} {
if _, ok := cfg.customHeaders[key]; !ok {
t.Errorf("customHeaders missing required key %q", key)
}
}
}

// TestEmbeddingsEndpointConfig verifies the embeddings endpoint entry exposes
// the expected path, method, and request body fields.
func TestEmbeddingsEndpointConfig(t *testing.T) {
cfg, ok := endpointConfigs["embeddings"]
if !ok {
t.Fatalf("endpointConfigs[\"embeddings\"] missing")
}
if cfg.path != "/v1/embeddings" {
t.Errorf("path = %q, want %q", cfg.path, "/v1/embeddings")
}
if cfg.method != http.MethodPost {
t.Errorf("method = %q, want %q", cfg.method, http.MethodPost)
}
if got, want := cfg.requestBody["model"], "text-embedding-3-small"; got != want {
t.Errorf("requestBody[model] = %v, want %v", got, want)
}
if got, want := cfg.requestBody["input"], "hello world"; got != want {
t.Errorf("requestBody[input] = %v, want %v", got, want)
}
}

// TestKimiResponsesCapability verifies Kimi is marked as not supporting the
// responses endpoint in the provider capability map.
func TestKimiResponsesCapability(t *testing.T) {
caps, ok := providerCapabilities["kimi"]
if !ok {
t.Fatalf("providerCapabilities[\"kimi\"] missing")
}
if caps["responses"] != false {
t.Errorf("providerCapabilities[\"kimi\"][\"responses\"] = %v, want false", caps["responses"])
}
}

// TestLegacyProvidersHaveNilCustomHeaders verifies the legacy provider entries
// (openai, groq) did not accidentally grow a customHeaders map.
func TestLegacyProvidersHaveNilCustomHeaders(t *testing.T) {
for _, name := range []string{"openai", "groq"} {
cfg, ok := providerConfigs[name]
if !ok {
t.Errorf("providerConfigs[%q] missing", name)
continue
}
if cfg.customHeaders != nil {
t.Errorf("providerConfigs[%q].customHeaders = %v, want nil", name, cfg.customHeaders)
}
}
}
23 changes: 22 additions & 1 deletion config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ server:
enable_passthrough_routes: true # expose /p/{provider}/{endpoint} passthrough routes
allow_passthrough_v1_alias: true # allow /p/{provider}/v1/... while keeping /p/{provider}/... canonical
user_path_header: "X-GoModel-User-Path" # env: USER_PATH_HEADER; inbound header used for user_path scoping
enabled_passthrough_providers: ["openai", "anthropic", "openrouter", "zai", "vllm", "deepseek", "bailian"] # providers enabled on /p/{provider}/...
enabled_passthrough_providers: ["openai", "anthropic", "openrouter", "zai", "vllm", "deepseek", "bailian", "kimi"] # providers enabled on /p/{provider}/...
realtime_enabled: true # env: REALTIME_ENABLED; expose /v1/realtime websocket and /p/{provider}/v1/realtime upgrades (OpenAI only)

models:
Expand Down Expand Up @@ -307,6 +307,27 @@ providers:
# Optional: set this only when vllm serve was started with --api-key.
# api_key: "token-abc123"

kimi:
type: kimi
api_key: "${KIMI_API_KEY}"
# Kimi requires a set of non-standard "ZooCode" headers on every request.
# Uncomment and tailor the block below for your client identity.
# custom_upstream_headers:
# User-Agent: "MyApp/1.0"
# X-Title: "MyApp"
# X-Stainless-Os: "Linux"
# X-Stainless-Arch: "x64"
# X-Stainless-Runtime: "node"
# X-Stainless-Runtime-Version: "20.0.0"
# X-Stainless-Package-Version: "1.0.0"
# X-Stainless-Lang: "js"
# Http-Referer: "https://myapp.example.com"
# Accept: "application/json"
# Accept-Encoding: "gzip, deflate, br"
# Accept-Language: "en-US"
# Content-Type: "application/json"
# Sec-Fetch-Mode: "cors"

# Custom OpenAI-compatible provider
# my-provider:
# type: openai
Expand Down
99 changes: 99 additions & 0 deletions config/config_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package config

import (
"os"
"path/filepath"
"reflect"
"testing"
)

Expand All @@ -20,3 +22,100 @@ func TestLoad_FromEnvironment(t *testing.T) {
t.Errorf("expected port 9090, got %s", result.Config.Server.Port)
}
}

// TestLoad_ConfigExample_IncludesKimiProvider verifies that the example
// configuration registers the Kimi provider block (added in Task 1.6) and that
// the new `custom_upstream_headers` YAML key parses without errors. The test
// loads the example config via Load() and asserts the parsed RawProviders map
// contains a Kimi entry with the expected type and that a representative YAML
// snippet using `custom_upstream_headers` round-trips into
// RawProviderConfig.CustomUpstreamHeaders.
func TestLoad_ConfigExample_IncludesKimiProvider(t *testing.T) {
clearAllConfigEnvVars(t)

examplePath, err := filepath.Abs("config.example.yaml")
if err != nil {
t.Fatalf("failed to resolve config.example.yaml path: %v", err)
}
exampleData, err := os.ReadFile(examplePath)
if err != nil {
t.Fatalf("failed to read config.example.yaml: %v", err)
}
failoverExamplePath, err := filepath.Abs("failover.example.json")
if err != nil {
t.Fatalf("failed to resolve failover.example.json path: %v", err)
}
failoverExampleData, err := os.ReadFile(failoverExamplePath)
if err != nil {
t.Fatalf("failed to read failover.example.json: %v", err)
}

withTempDir(t, func(dir string) {
if err := os.MkdirAll(filepath.Join(dir, "config"), 0755); err != nil {
t.Fatalf("failed to create config directory: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "config.yaml"), exampleData, 0644); err != nil {
t.Fatalf("failed to write config/config.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "failover.example.json"), failoverExampleData, 0644); err != nil {
t.Fatalf("failed to write failover.example.json: %v", err)
}

result, err := Load()
if err != nil {
t.Fatalf("Load() of config.example.yaml failed: %v", err)
}

kimi, exists := result.RawProviders["kimi"]
if !exists {
t.Fatalf("expected RawProviders to contain a kimi entry; got keys: %v", keysOf(result.RawProviders))
}
if kimi.Type != "kimi" {
t.Fatalf("expected kimi provider type %q, got %q", "kimi", kimi.Type)
}
if kimi.APIKey != "${KIMI_API_KEY}" {
t.Fatalf("expected kimi api_key %q, got %q", "${KIMI_API_KEY}", kimi.APIKey)
}

// Verify `custom_upstream_headers` parses from a representative snippet.
withTempDir(t, func(dir2 string) {
yaml := `
providers:
kimi:
type: kimi
api_key: "test-key"
custom_upstream_headers:
User-Agent: "MyApp/1.0"
X-Title: "MyApp"
X-Stainless-Os: "Linux"
`
if err := os.WriteFile(filepath.Join(dir2, "config.yaml"), []byte(yaml), 0644); err != nil {
t.Fatalf("failed to write config.yaml: %v", err)
}
res, err := Load()
if err != nil {
t.Fatalf("Load() of custom_upstream_headers snippet failed: %v", err)
}
cfg, exists := res.RawProviders["kimi"]
if !exists {
t.Fatalf("expected RawProviders to contain kimi from snippet")
}
want := map[string]string{
"User-Agent": "MyApp/1.0",
"X-Title": "MyApp",
"X-Stainless-Os": "Linux",
}
if !reflect.DeepEqual(cfg.CustomUpstreamHeaders, want) {
t.Fatalf("CustomUpstreamHeaders = %v, want %v", cfg.CustomUpstreamHeaders, want)
}
})
})
}

func keysOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
2 changes: 1 addition & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,7 @@ func TestLoad_ConfigExample_UsesNestedModelCacheSettings(t *testing.T) {
t.Fatalf("expected Cache.Model.Redis to be nil in example config, got %+v", result.Config.Cache.Model.Redis)
}
gotProviders := result.Config.Server.EnabledPassthroughProviders
wantProviders := []string{"openai", "anthropic", "openrouter", "zai", "vllm", "deepseek", "bailian"}
wantProviders := []string{"openai", "anthropic", "openrouter", "zai", "vllm", "deepseek", "bailian", "kimi"}
if !reflect.DeepEqual(gotProviders, wantProviders) {
t.Fatalf("Server.EnabledPassthroughProviders = %v, want %v", gotProviders, wantProviders)
}
Expand Down
Loading
Loading