From dbd8841a5ba6086fbcdd336465c68c46f4f890a8 Mon Sep 17 00:00:00 2001 From: weselben Date: Sun, 5 Jul 2026 00:05:35 +0200 Subject: [PATCH 1/5] docs: add custom headers feature guide and Kimi provider docs Document per-provider custom upstream headers and passthrough user headers under docs/features/custom-headers.mdx. Add the Moonshot Kimi provider page and overview entry. Update CLAUDE.md and config/config.example.yaml references. --- CLAUDE.md | 4 +- config/config.example.yaml | 23 ++- docs/docs.json | 2 + docs/features/custom-headers.mdx | 245 +++++++++++++++++++++++++++++++ docs/providers/kimi.mdx | 215 +++++++++++++++++++++++++++ docs/providers/overview.mdx | 1 + 6 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 docs/features/custom-headers.mdx create mode 100644 docs/providers/kimi.mdx diff --git a/CLAUDE.md b/CLAUDE.md index db6bbde5..58557072 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:///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. @@ -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..resilience.retry.*` and `providers..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), `[_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), `[_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..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. diff --git a/config/config.example.yaml b/config/config.example.yaml index be1506e4..3999d45a 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -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: @@ -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 diff --git a/docs/docs.json b/docs/docs.json index af5d6b37..2b4cc720 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -64,6 +64,7 @@ "features/virtual-models", "features/user-path", "features/passthrough-api", + "features/custom-headers", "features/budgets", "features/cost-tracking", "features/labelling", @@ -125,6 +126,7 @@ "providers/gemini", "providers/deepseek", "providers/bailian", + "providers/kimi", "providers/xiaomi", "providers/opencode-go", "providers/vllm", diff --git a/docs/features/custom-headers.mdx b/docs/features/custom-headers.mdx new file mode 100644 index 00000000..f34b2102 --- /dev/null +++ b/docs/features/custom-headers.mdx @@ -0,0 +1,245 @@ +--- +title: "Custom Headers" +description: "Forward and customize HTTP headers between clients and upstream providers, with a security skip floor and skip/allow list modes." +icon: "filter" +tag: "Stable" +--- + +## Overview + +`HeaderOverridesConfig` controls how GoModel handles HTTP headers on requests +to upstream providers. Three independent capabilities are exposed per +provider: + +- **Custom upstream headers**: Static headers GoModel attaches to every + outbound request to a specific provider. +- **Passthrough user headers**: Forward a curated set of client-supplied + request headers to the upstream provider. +- **Skip / allow list**: Constrain passthrough by header name, in either + `skip` (default) or `allow` mode. + +All three are governed by a **security skip floor**: a non-configurable set of +header names that is always stripped before any other rule runs. + +## Configuration fields + +`ProviderConfig` (see [`internal/providers/config.go`](../../internal/providers/config.go:30)) exposes four header-related fields per provider: + +```yaml +providers: + openai: + api_key: ${OPENAI_API_KEY} + custom_upstream_headers: + X-Request-Source: gomodel + passthrough_user_headers: true + passthrough_user_headers_skip: + - X-Org-Id + - X-Trace-Id + passthrough_user_headers_skip_mode: skip +``` + +| Field | Type | Purpose | +|-------|------|---------| +| `custom_upstream_headers` | `map[string]string` | Static headers GoModel attaches to every request. | +| `passthrough_user_headers` | `bool` | Enable forwarding of client headers. | +| `passthrough_user_headers_skip` | `[]string` | Header names that drive skip/allow matching. | +| `passthrough_user_headers_skip_mode` | `string` | `"skip"` (default) or `"allow"`. | + +When **no** header field is set, the factory returns `nil` for +`HeaderOverrides` (see [`internal/providers/factory.go`](../../internal/providers/factory.go:123)) and the provider behaves like a plain proxy. + +## Security skip floor + +Before any user-defined rule is evaluated, GoModel strips a fixed set of +headers from every request that flows through header overrides. The floor is +implemented in [`IsHeaderBlocked`](../../internal/providers/headers.go:45) and +applies to **both** passthrough and custom upstream headers: + +1. **Credential headers** via `core.IsCredentialHeader` — any header + recognized as carrying credentials is dropped. This includes + `Authorization` and `X-Api-Key`, plus anything else in the internal + credential list (`internal/core/credential_headers.go`). +2. **`X-GoModel-User-Path`** — the internal hop-to-hop routing header. +3. **Configured `userPathAlias`** — if the gateway was started with a custom + user-path header name, that alias is also blocked. + +The floor is a hard rule. A header in the skip list, allow list, or +`custom_upstream_headers` map cannot override it. Matching is +case-insensitive and trims surrounding whitespace so client casing does not +matter. + +## Skip / allow modes + +When `passthrough_user_headers` is `true`, `passthrough_user_headers_skip` +constrains which client headers are forwarded. The +`passthrough_user_headers_skip_mode` field chooses how the list is interpreted +(see [`shouldForward`](../../internal/providers/headers.go:130)): + +### `skip` (default) + +Forward every client header **except** the names in +`passthrough_user_headers_skip`. Unknown modes fall through to `skip`. + +```yaml +providers: + openai: + passthrough_user_headers: true + passthrough_user_headers_skip: + - X-Org-Id + - X-Trace-Id + passthrough_user_headers_skip_mode: skip +``` + +Result: `X-Org-Id` and `X-Trace-Id` are dropped, every other client header +is forwarded (subject to the security skip floor). + +### `allow` + +Forward **only** the names in `passthrough_user_headers_skip` and drop +everything else passthrough would forward. + +```yaml +providers: + anthropic: + passthrough_user_headers: true + passthrough_user_headers_skip: + - X-Request-Id + - X-Tenant + passthrough_user_headers_skip_mode: allow +``` + +Result: only `X-Request-Id` and `X-Tenant` are forwarded; every other client +header (besides those blocked by the security floor) is dropped. + + + In `allow` mode the list names the headers you keep, not the headers you + block. Choosing the wrong mode for the same list inverts the outcome. + + +Matching against the skip / allow list is **case-insensitive** and trims +whitespace around each entry, so `"X-Org-Id"`, `"x-org-id"`, and +`" X-Org-Id "` are all equivalent. + +## Interaction rules + +The three header sources compose in a defined order. The rules below come +from [`ApplyHeaderOverrides`](../../internal/providers/headers.go:82) and +[`applyPassthroughHeaders`](../../internal/providers/headers.go:100): + +1. **No header config = no override**. If both `passthrough_user_headers` + is `false` **and** `custom_upstream_headers` is empty, header overrides + are a no-op. +2. **Passthrough takes precedence over custom upstream headers**. When + `passthrough_user_headers` is `true`, the contents of + `custom_upstream_headers` are ignored (a debug log records this) and only + passthrough logic runs. +3. **Static headers run alone when passthrough is off**. If + `custom_upstream_headers` is populated and `passthrough_user_headers` is + `false`, those static headers are attached after the security skip floor + is applied. +4. **Floor first, list second**. In passthrough mode the security skip floor + is evaluated before the skip / allow list, so blocked names never leak + through regardless of mode or list contents. +5. **Forwarded values overwrite provider defaults**. For each header that + passes the floor and the skip / allow list, GoModel deletes any existing + value on the outgoing request and appends each client value, preserving + multi-value headers. +6. **Empty `skip_mode` defaults to `skip`**. An unknown mode string is + treated as `skip` (conservative default). + +### Decision walkthrough + +For each client header, the request proceeds upstream only if every step +lets it through: + +```text +client header + └─ security skip floor (credential? x-gomodel-user-path? alias match?) + └─ no → drop + └─ yes → apply skip / allow list + ├─ skip mode + in list → drop + ├─ skip mode + not in list → forward + ├─ allow mode + in list → forward + └─ allow mode + not in list → drop +``` + +## Examples + +### Forward everything except internal trace headers + +```yaml +providers: + openai: + api_key: ${OPENAI_API_KEY} + passthrough_user_headers: true + passthrough_user_headers_skip: + - X-Internal-Span-Id + passthrough_user_headers_skip_mode: skip +``` + +A client request with the headers below forwards only the public ones: + +```http +Authorization: Bearer change-me # stripped by floor +X-Internal-Span-Id: abc123 # stripped by skip mode +X-Tenant: acme # forwarded +User-Agent: my-app/1.4.2 # forwarded +``` + +### Allowlist a small set of vetted headers + +```yaml +providers: + anthropic: + api_key: ${ANTHROPIC_API_KEY} + passthrough_user_headers: true + passthrough_user_headers_skip: + - X-Request-Id + - X-Tenant + passthrough_user_headers_skip_mode: allow +``` + +A client request forwards only the listed headers plus provider defaults: + +```http +X-Request-Id: req-9f2c # forwarded (allowlisted) +X-Tenant: acme # forwarded (allowlisted) +X-Anything-Else: ignored # dropped (not allowlisted) +Authorization: Bearer change-me # stripped by floor +``` + +### Attach static identity headers without passthrough + +```yaml +providers: + groq: + api_key: ${GROQ_API_KEY} + custom_upstream_headers: + X-Source-System: gomodel + X-Region: eu-west-1 +``` + +Both static headers are attached to every Groq request. `Authorization`, +`X-Api-Key`, `X-GoModel-User-Path`, and the configured user-path alias are +stripped if a client tries to set them anyway. + +## Limitations + +`custom_upstream_headers` and `passthrough_user_headers` are not yet implemented for: + +- **OpenRouter** +- **Native SDK providers**: Anthropic, Gemini, Vertex, Bedrock, and other providers that use vendor-specific SDKs + +For these providers, header configuration is ignored and requests are forwarded without custom headers or passthrough. + +## Related source + +- [`internal/providers/headers.go`](../../internal/providers/headers.go:1) + — `HeaderOverridesConfig`, `IsHeaderBlocked`, `shouldForward`, + `ApplyHeaderOverrides`. +- [`internal/providers/config.go`](../../internal/providers/config.go:30) + — `ProviderConfig` header fields. +- [`internal/providers/factory.go`](../../internal/providers/factory.go:123) + — `buildHeaderOverrides` (returns `nil` when no header config is present). +- [`internal/core/credential_headers.go`](../../internal/core/credential_headers.go:22) + — the canonical credential header list. diff --git a/docs/providers/kimi.mdx b/docs/providers/kimi.mdx new file mode 100644 index 00000000..ccac46eb --- /dev/null +++ b/docs/providers/kimi.mdx @@ -0,0 +1,215 @@ +--- +title: "Kimi" +description: "Configure Moonshot Kimi in GoModel with the default api.kimi.com coding endpoint, OpenAI-compatible chat and embeddings, and ZooCode identity headers." +icon: "moon" +--- + +[Kimi](https://www.kimi.com/) is Moonshot AI's coding-focused API. GoModel +ships a native `kimi` provider that wraps the OpenAI-compatible adapter, so +chat completions, streaming, `/v1/responses` (translated to chat), and +`/v1/embeddings` all work out of the box. + +## Configure + +```bash +KIMI_API_KEY=sk-... +``` + +Or in `config.yaml`: + +```yaml +providers: + kimi: + type: kimi + api_key: "${KIMI_API_KEY}" + # Optional: override the default endpoint. + # base_url: "https://api.kimi.com/coding/v1" +``` + +The default base URL is `https://api.kimi.com/coding/v1` (see +[`internal/providers/kimi/kimi.go:13`](../../internal/providers/kimi/kimi.go:13)). +Authentication uses `Authorization: Bearer `. Set `KIMI_BASE_URL` +only when pointing GoModel at a Kimi-compatible proxy or a private deployment. + + + +```bash Docker (.env file) +docker run --rm -p 8080:8080 --env-file .env enterpilot/gomodel +``` + +```bash Docker (inline -e) +docker run --rm -p 8080:8080 \ + -e GOMODEL_MASTER_KEY="change-me" \ + -e KIMI_API_KEY="sk-..." \ + -e KIMI_BASE_URL="https://api.kimi.com/coding/v1" \ + enterpilot/gomodel +``` + +```bash Binary (make build) +make build +KIMI_API_KEY=sk-... ./bin/gomodel +``` + + + +## Model allowlist + +Instead of using `KIMI_MODELS` environment variable, you can configure model +allowlists directly in [`config.yaml`](../../config.yaml): + +```yaml +models: + configured_provider_models_mode: allowlist + +providers: + kimi: + type: kimi + api_key: "${KIMI_API_KEY}" + models: + - kimi-for-coding + - bge_m3_embed +``` + +The `models` section under each provider explicitly lists the model IDs that +GoModel should expose. When `configured_provider_models_mode` is set to +`allowlist`, only these models are available — all others from the upstream +provider are ignored. This is useful for: +- Restricting access to specific models only +- Explicitly registering embedding models that aren't advertised upstream +- Creating minimal configurations with known model sets + + + `configured_provider_models_mode` is a global setting. In `allowlist` mode, + only providers that explicitly define a `models` list are restricted to + those models. Providers without a `models` list continue to fetch and expose + their full upstream catalog. This means you can restrict Kimi to a small set + of models while leaving other providers unchanged. + + + + Model IDs in the YAML `models` list should match the exact IDs returned by + the upstream provider (without provider prefix). For Kimi, use + `kimi-for-coding` not `kimi/kimi-for-coding` — GoModel adds the provider + prefix automatically for client-facing model IDs. + + +## Verify + +```bash +curl -s http://localhost:8080/v1/chat/completions \ + -H "Authorization: Bearer change-me" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "kimi/kimi-for-coding", + "messages": [{"role": "user", "content": "Reply with exactly ok."}] + }' +``` + +`GET /v1/models` proxies Kimi's upstream model list, exposing IDs prefixed by +provider name, e.g. `kimi/kimi-for-coding`. + +## Embedding model registration + +Kimi's `/v1/embeddings` works through the same OpenAI-compatible adapter as +chat, but embedding model IDs are usually not advertised by the upstream +`/v1/models` endpoint. Register the IDs you want to expose with +`KIMI_MODELS`: + +```bash +KIMI_MODELS=kimi-for-coding,kimi-embedding +``` + +`KIMI_MODELS` is a comma-separated allowlist. Whitespace around entries is +ignored. By default, `CONFIGURED_PROVIDER_MODELS_MODE=fallback` keeps the +upstream `/v1/models` catalog and only fills in models that are missing — set +it to `allowlist` to expose only the IDs listed in `KIMI_MODELS` and skip the +upstream call. + +```bash +curl -s http://localhost:8080/v1/embeddings \ + -H "Authorization: Bearer change-me" \ + -H "Content-Type: application/json" \ + -d '{"model": "kimi/kimi-embedding", "input": "hello"}' +``` + + + Embedding models must be listed in `KIMI_MODELS` to be routable through + GoModel. Without the configured list, requests that target an embedding + ID will get a `model_not_found` from the upstream Kimi catalog even though + the `/v1/embeddings` endpoint itself is available. + + +## ZooCode identity headers + +Kimi's coding endpoint accepts [ZooCode](https://www.kimi.com/) identity +headers (for example `X-ZooCode-User`, `X-ZooCode-Plan`, or +`X-ZooCode-Team`) so requests are attributed to a ZooCode workspace. Attach +them to every outbound Kimi call with +`custom_upstream_headers`, and forward client-supplied variants with +`passthrough_user_headers`. See +[Custom Headers](/features/custom-headers) for the +full configuration reference. + +Static headers only (no passthrough): + +```yaml +providers: + kimi: + type: kimi + api_key: "${KIMI_API_KEY}" + custom_upstream_headers: + X-ZooCode-User: ops@gomodel.example + X-ZooCode-Plan: enterprise + X-Source-System: gomodel +``` + +Each `custom_upstream_headers` entry is attached verbatim to every Kimi +request. `Authorization` and other credential headers are stripped before +overrides run, so this map cannot leak secrets from one tenant to another. + +Forward client-supplied headers (allowlist mode keeps the surface small): + +```yaml +providers: + kimi: + type: kimi + api_key: "${KIMI_API_KEY}" + passthrough_user_headers: true + passthrough_user_headers_skip: + - X-ZooCode-User + - X-ZooCode-Plan + - X-ZooCode-Team + passthrough_user_headers_skip_mode: allow +``` + +With `skip_mode: allow`, only the listed ZooCode headers reach the +upstream — every other client header is dropped, so the ZooCode identity is +the only thing the client can influence. Use `skip_mode: skip` (the default) +to forward all client headers except the ones in the list. + +## Multiple Kimi instances + +Use suffixed env vars to register more than one Kimi endpoint without +`config.yaml`: + +```bash +KIMI_BASE_URL=https://api.kimi.com/coding/v1 +KIMI_CN_BASE_URL=https://api.kimi.cn/coding/v1 +``` + +`KIMI_BASE_URL` registers `kimi`. `KIMI_CN_BASE_URL` registers `kimi-cn` +(suffix is lowercased, underscores become hyphens). Add `KIMI_CN_API_KEY` +and `KIMI_CN_MODELS` to scope a configured model list to that instance. + + + Provider type stays `kimi` for every instance — only the public provider ID + changes. The alias is the only way clients can disambiguate them: route + `kimi/...` to the default instance and `kimi-cn/...` to the second one via + the standard provider-qualified model ID syntax. + + +## Not yet integrated + +- Native batch APIs. +- OpenAI-compatible files lifecycle. +- Responses lifecycle utility endpoints. diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 8f247037..fd4df769 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -37,6 +37,7 @@ support, not every individual model capability exposed by an upstream provider. | Oracle GenAI | `ORACLE_API_KEY` + `ORACLE_BASE_URL` | `openai.gpt-oss-120b` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [Oracle GenAI](/providers/oracle) | | Ollama | `OLLAMA_BASE_URL` | `llama3.2` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | [Ollama](/providers/multiple-ollama) | | vLLM | `VLLM_BASE_URL` (`VLLM_API_KEY` optional) | `meta-llama/Llama-3.1-8B-Instruct` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [vLLM](/providers/vllm) | +| Moonshot Kimi | `KIMI_API_KEY` (`KIMI_BASE_URL` optional) | `kimi-for-coding` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | [Moonshot Kimi](/providers/kimi) | | Amazon Bedrock | `BEDROCK_BASE_URL` (region or endpoint) + AWS credentials | `anthropic.claude-3-5-haiku-20241022-v1:0` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | [Amazon Bedrock](/providers/bedrock) | ✅ Supported ❌ Unsupported From 50d0cc1a0445456ec32a539fdff359b558b00be2 Mon Sep 17 00:00:00 2001 From: weselben Date: Sun, 5 Jul 2026 00:06:10 +0200 Subject: [PATCH 2/5] feat(headers): add custom upstream and passthrough header overrides Introduce HeaderOverridesConfig, ApplyHeaderOverrides, and per-provider factory wiring. Support static custom headers and optional client-header passthrough with skip/allow lists and a security floor. Gate middleware registration on any provider enabling passthrough_user_headers. --- config/providers.go | 13 ++ internal/app/app.go | 25 +++ internal/providers/config.go | 50 ++++++ internal/providers/deepseek/deepseek.go | 26 ++- internal/providers/factory.go | 39 ++++- internal/providers/headers.go | 159 ++++++++++++++++++ internal/providers/init.go | 11 ++ internal/providers/ollama/ollama.go | 32 +++- .../providers/openai/compatible_provider.go | 17 ++ internal/providers/vllm/vllm.go | 36 +++- internal/providers/xai/xai.go | 26 ++- internal/server/http.go | 8 + internal/server/passthrough_headers.go | 27 +++ 13 files changed, 437 insertions(+), 32 deletions(-) create mode 100644 internal/providers/headers.go create mode 100644 internal/server/passthrough_headers.go diff --git a/config/providers.go b/config/providers.go index 20876024..9f429868 100644 --- a/config/providers.go +++ b/config/providers.go @@ -18,5 +18,18 @@ type RawProviderConfig struct { ServiceAccountJSONBase64 string `yaml:"service_account_json_base64"` GCPScope string `yaml:"gcp_scope"` Models []RawProviderModel `yaml:"models"` + // CustomUpstreamHeaders are extra HTTP headers the gateway attaches to every + // outbound request to this provider, on top of the auth/transport defaults. + CustomUpstreamHeaders map[string]string `yaml:"custom_upstream_headers"` + // PassthroughUserHeaders enables forwarding a curated set of client-supplied + // request headers to the upstream provider. + PassthroughUserHeaders bool `yaml:"passthrough_user_headers"` + // PassthroughUserHeadersSkip lists header names to exclude from passthrough. + // Interpretation depends on PassthroughUserHeadersSkipMode. + PassthroughUserHeadersSkip []string `yaml:"passthrough_user_headers_skip"` + // PassthroughUserHeadersSkipMode controls how the skip list is applied: + // "skip" (default) removes the listed headers, "only" keeps only the + // listed headers and drops everything else passthrough would forward. + PassthroughUserHeadersSkipMode string `yaml:"passthrough_user_headers_skip_mode"` Resilience *RawResilienceConfig `yaml:"resilience"` } diff --git a/internal/app/app.go b/internal/app/app.go index e1a40abf..000e189f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -177,6 +177,17 @@ func New(ctx context.Context, cfg Config) (*App, error) { app.providers = providerResult closers = append(closers, app.providers.Close) + // Propagate the user-path header to the factory so per-request providers + // can read it from options without re-parsing the server config. + if cfg.Factory != nil { + cfg.Factory.SetUserPathHeader(appCfg.Server.UserPathHeader) + } + + // The passthrough header capture middleware must run only when at least one + // resolved provider has passthrough_user_headers enabled. Registering it + // otherwise would add per-request work for zero functional benefit. + passthroughEnabled := anyProviderHasPassthroughUserHeaders(providerResult.ResolvedProviders) + // Initialize audit logging auditResult, err := auditlog.New(ctx, appCfg) if err != nil { @@ -448,6 +459,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { RealtimeEnabled: appCfg.Server.RealtimeEnabled, AllowPassthroughV1Alias: &allowPassthroughV1Alias, UserPathHeader: appCfg.Server.UserPathHeader, + PassthroughUserHeadersEnabled: passthroughEnabled, SwaggerEnabled: swaggerEnabled, Tagging: taggingResult.Service, } @@ -1173,3 +1185,16 @@ func semanticResponseCacheConfiguredFromResponse(cfg config.ResponseCacheConfig) func failoverFeatureEnabledGlobally(cfg *config.Config) bool { return cfg != nil && cfg.Failover.Enabled } + +// anyProviderHasPassthroughUserHeaders reports whether any resolved provider +// opts in to forwarding user-supplied request headers upstream. When true, the +// server registers the PassthroughHeaderCapture middleware to populate the +// per-request header context used by the provider layer. +func anyProviderHasPassthroughUserHeaders(resolved map[string]providers.ProviderConfig) bool { + for _, p := range resolved { + if p.PassthroughUserHeaders { + return true + } + } + return false +} diff --git a/internal/providers/config.go b/internal/providers/config.go index dd2a5bfc..751ecabe 100644 --- a/internal/providers/config.go +++ b/internal/providers/config.go @@ -28,6 +28,19 @@ type ProviderConfig struct { ServiceAccountJSONBase64 string GCPScope string Models []string + // CustomUpstreamHeaders are extra HTTP headers the gateway attaches to every + // outbound request to this provider, on top of the auth/transport defaults. + CustomUpstreamHeaders map[string]string + // PassthroughUserHeaders enables forwarding a curated set of client-supplied + // request headers to the upstream provider. + PassthroughUserHeaders bool + // PassthroughUserHeadersSkip lists header names to exclude from passthrough. + // Interpretation depends on PassthroughUserHeadersSkipMode. + PassthroughUserHeadersSkip []string + // PassthroughUserHeadersSkipMode controls how the skip list is applied: + // "skip" (default) removes the listed headers, "only" keeps only the + // listed headers and drops everything else passthrough would forward. + PassthroughUserHeadersSkipMode string // ModelMetadataOverrides holds operator-supplied metadata keyed by raw model // ID (as it appears in the provider's /models response). The registry merges // these onto remote-registry metadata after enrichment; non-zero fields here @@ -91,6 +104,7 @@ const ( providerEnvFieldServiceAccountJSON providerEnvFieldServiceAccountJSONBase64 providerEnvFieldGCPScope + providerEnvFieldPassthroughUserHeaders ) type providerEnvSource struct { @@ -114,6 +128,9 @@ type providerEnvValues struct { ServiceAccountJSONBase64 string GCPScope string Models []string + // PassthroughUserHeaders is intentionally NOT included in empty() — a + // passthrough env var alone must not trigger provider discovery. + PassthroughUserHeaders bool } func (v providerEnvValues) empty() bool { @@ -188,6 +205,8 @@ func collectProviderEnvValues(prefix string, spec DiscoveryConfig, environ []str values.ServiceAccountJSONBase64 = value case providerEnvFieldGCPScope: values.GCPScope = value + case providerEnvFieldPassthroughUserHeaders: + values.PassthroughUserHeaders = parseBool(value) } groups[suffix] = values } @@ -214,6 +233,7 @@ func parseProviderEnvKey(prefix, key string, spec DiscoveryConfig) (string, prov name string field providerEnvField }{ + {name: "PASSTHROUGH_USER_HEADERS", field: providerEnvFieldPassthroughUserHeaders}, {name: "API_VERSION", field: providerEnvFieldAPIVersion}, {name: "BASE_URL", field: providerEnvFieldBaseURL}, {name: "AUTH_TYPE", field: providerEnvFieldAuthType}, @@ -347,6 +367,7 @@ func (v providerEnvValues) rawConfig(providerType string, spec DiscoveryConfig) ServiceAccountJSONBase64: v.ServiceAccountJSONBase64, GCPScope: v.GCPScope, Models: rawProviderModelsFromIDs(v.Models), + PassthroughUserHeaders: v.PassthroughUserHeaders, } } @@ -400,6 +421,9 @@ func overlayProviderEnvValues(existing config.RawProviderConfig, values provider if len(values.Models) > 0 { existing.Models = rawProviderModelsFromIDs(values.Models) } + if values.PassthroughUserHeaders { + existing.PassthroughUserHeaders = true + } return existing } @@ -539,6 +563,19 @@ func parseCSVEnvList(value string) []string { return values } +// parseBool interprets a permissive truthy env-var token. It returns true only +// for case-insensitive matches of "true", "1", "yes", or "on"; any other value +// (including empty) returns false. Used for boolean env overlays where the +// presence of the var alone should not flip state. +func parseBool(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "yes", "on": + return true + default: + return false + } +} + func isUnresolvedEnvPlaceholder(value string) bool { if !strings.HasPrefix(value, "${") || !strings.HasSuffix(value, "}") || len(value) <= 3 { return false @@ -636,6 +673,10 @@ func buildProviderConfig(raw config.RawProviderConfig, global config.ResilienceC ServiceAccountJSONBase64: raw.ServiceAccountJSONBase64, GCPScope: raw.GCPScope, Models: config.ProviderModelIDs(raw.Models), + CustomUpstreamHeaders: raw.CustomUpstreamHeaders, + PassthroughUserHeaders: raw.PassthroughUserHeaders, + PassthroughUserHeadersSkip: raw.PassthroughUserHeadersSkip, + PassthroughUserHeadersSkipMode: defaultPassthroughSkipMode(raw.PassthroughUserHeadersSkipMode), ModelMetadataOverrides: config.ProviderModelMetadataOverrides(raw.Models), Resilience: global, } @@ -685,6 +726,15 @@ func normalizeProviderType(raw config.RawProviderConfig) string { return providerType } +// defaultPassthroughSkipMode returns "skip" when the raw value is empty, and +// otherwise passes through the operator-supplied mode unchanged. +func defaultPassthroughSkipMode(raw string) string { + if strings.TrimSpace(raw) == "" { + return "skip" + } + return raw +} + // rawProviderModelsFromIDs wraps a plain string slice into RawProviderModel // entries. Used for env-var-sourced model lists where metadata is never present. func rawProviderModelsFromIDs(ids []string) []config.RawProviderModel { diff --git a/internal/providers/deepseek/deepseek.go b/internal/providers/deepseek/deepseek.go index c33cb7b9..2feed930 100644 --- a/internal/providers/deepseek/deepseek.go +++ b/internal/providers/deepseek/deepseek.go @@ -26,15 +26,21 @@ var Registration = providers.Registration{ // Provider implements the core.Provider interface for DeepSeek. type Provider struct { - client *llmclient.Client - apiKey string + client *llmclient.Client + apiKey string + headerOverrides *providers.HeaderOverridesConfig + userPathAlias string } var _ core.Provider = (*Provider)(nil) // New creates a new DeepSeek provider. func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { - p := &Provider{apiKey: cfg.APIKey} + p := &Provider{ + apiKey: cfg.APIKey, + headerOverrides: opts.HeaderOverrides, + userPathAlias: opts.UserPathHeader, + } clientCfg := llmclient.Config{ ProviderName: "deepseek", BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), @@ -47,12 +53,17 @@ func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Prov } // NewWithHTTPClient creates a new DeepSeek provider with a custom HTTP client. -// If httpClient is nil, http.DefaultClient is used. -func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { +// If httpClient is nil, http.DefaultClient is used. headerOverrides and +// userPathAlias are optional; pass nil and "" to disable header overrides. +func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks, headerOverrides *providers.HeaderOverridesConfig, userPathAlias string) *Provider { if httpClient == nil { httpClient = http.DefaultClient } - p := &Provider{apiKey: apiKey} + p := &Provider{ + apiKey: apiKey, + headerOverrides: headerOverrides, + userPathAlias: userPathAlias, + } cfg := llmclient.DefaultConfig("deepseek", providers.ResolveBaseURL(baseURL, defaultBaseURL)) cfg.Hooks = hooks p.client = llmclient.NewWithHTTPClient(httpClient, cfg, p.setHeaders) @@ -69,6 +80,9 @@ func (p *Provider) setHeaders(req *http.Request) { AuthScheme: "Bearer ", RequestIDHeader: "X-Request-Id", }) + if p.headerOverrides != nil { + providers.ApplyHeaderOverrides(req, *p.headerOverrides, p.userPathAlias) + } } // adaptChatRequest rewrites GoModel's common reasoning shape into DeepSeek's diff --git a/internal/providers/factory.go b/internal/providers/factory.go index bbfa8931..4dd8566a 100644 --- a/internal/providers/factory.go +++ b/internal/providers/factory.go @@ -13,9 +13,11 @@ import ( // ProviderOptions bundles runtime settings passed from the factory to provider constructors. type ProviderOptions struct { - Hooks llmclient.Hooks - Models []string - Resilience config.ResilienceConfig + Hooks llmclient.Hooks + Models []string + Resilience config.ResilienceConfig + UserPathHeader string + HeaderOverrides *HeaderOverridesConfig } // ProviderConstructor is the constructor signature for providers. @@ -46,6 +48,7 @@ type ProviderFactory struct { discoveryConfigs map[string]DiscoveryConfig passthroughEnrichers map[string]core.PassthroughSemanticEnricher hooks llmclient.Hooks + userPathHeader string } // NewProviderFactory creates a new provider factory instance. @@ -64,6 +67,13 @@ func (f *ProviderFactory) SetHooks(hooks llmclient.Hooks) { f.hooks = hooks } +// SetUserPathHeader configures the user path header name for all providers created by this factory. +func (f *ProviderFactory) SetUserPathHeader(header string) { + f.mu.Lock() + defer f.mu.Unlock() + f.userPathHeader = header +} + // Add adds a provider constructor to the factory. // Panics if reg.Type is empty or reg.New is nil — both are programming errors // caught at startup, not runtime conditions. @@ -90,6 +100,7 @@ func (f *ProviderFactory) Create(cfg ProviderConfig) (core.Provider, error) { f.mu.RLock() builder, ok := f.builders[cfg.Type] hooks := f.hooks + userPathHeader := f.userPathHeader f.mu.RUnlock() if !ok { @@ -97,14 +108,30 @@ func (f *ProviderFactory) Create(cfg ProviderConfig) (core.Provider, error) { } opts := ProviderOptions{ - Hooks: hooks, - Models: cfg.Models, - Resilience: cfg.Resilience, + Hooks: hooks, + Models: cfg.Models, + Resilience: cfg.Resilience, + UserPathHeader: userPathHeader, + HeaderOverrides: buildHeaderOverrides(cfg), } return builder(cfg, opts), nil } +// buildHeaderOverrides constructs a HeaderOverridesConfig from ProviderConfig header fields. +// Returns nil when no header configuration is present to preserve the no-operation path. +func buildHeaderOverrides(cfg ProviderConfig) *HeaderOverridesConfig { + if !cfg.PassthroughUserHeaders && len(cfg.CustomUpstreamHeaders) == 0 && len(cfg.PassthroughUserHeadersSkip) == 0 { + return nil + } + return &HeaderOverridesConfig{ + CustomUpstreamHeaders: cfg.CustomUpstreamHeaders, + PassthroughUserHeaders: cfg.PassthroughUserHeaders, + SkipHeaders: cfg.PassthroughUserHeadersSkip, + SkipMode: cfg.PassthroughUserHeadersSkipMode, + } +} + // discoveryConfigsSnapshot returns provider discovery metadata keyed by provider type. func (f *ProviderFactory) discoveryConfigsSnapshot() map[string]DiscoveryConfig { f.mu.RLock() diff --git a/internal/providers/headers.go b/internal/providers/headers.go new file mode 100644 index 00000000..fc298334 --- /dev/null +++ b/internal/providers/headers.go @@ -0,0 +1,159 @@ +package providers + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "gomodel/internal/core" +) + +// HeaderOverridesConfig holds per-provider header configuration. +type HeaderOverridesConfig struct { + // CustomUpstreamHeaders adds static headers to all provider requests. + CustomUpstreamHeaders map[string]string + + // PassthroughUserHeaders forwards all user headers to upstream. + PassthroughUserHeaders bool + + // SkipHeaders prevents forwarding specific headers to upstream. + SkipHeaders []string + + // SkipMode determines how SkipHeaders works: "skip" or "allow". + SkipMode string +} + +// passthroughCtxKey is the context key for storing passthrough headers. +type passthroughCtxKey struct{} + +// WithPassthroughHeaders stores headers in context for passthrough. +func WithPassthroughHeaders(ctx context.Context, h http.Header) context.Context { + return context.WithValue(ctx, passthroughCtxKey{}, h) +} + +// PassthroughHeadersFromContext retrieves passthrough headers from context. +func PassthroughHeadersFromContext(ctx context.Context) http.Header { + if h, ok := ctx.Value(passthroughCtxKey{}).(http.Header); ok { + return h + } + return nil +} + +// IsHeaderBlocked reports whether a header should be blocked from forwarding. +// Hard-coded skip floor: blocks credential headers, X-GoModel-User-Path, and configured alias. +func IsHeaderBlocked(name string, userPathAlias string) bool { + lower := strings.ToLower(strings.TrimSpace(name)) + + // Credential headers are always blocked + if core.IsCredentialHeader(name) { + return true + } + + // Internal header is always blocked + if lower == "x-gomodel-user-path" { + return true + } + + // Block configured alias + if userPathAlias != "" { + aliasLower := strings.ToLower(strings.TrimSpace(userPathAlias)) + if lower == aliasLower { + return true + } + } + + return false +} + +// FilterIncomingHeaders returns a filtered copy of headers, removing blocked ones. +func FilterIncomingHeaders(headers http.Header, userPathAlias string) http.Header { + filtered := make(http.Header) + for name, values := range headers { + if !IsHeaderBlocked(name, userPathAlias) { + filtered[name] = values + } + } + return filtered +} + +// ApplyHeaderOverrides applies header overrides to the request. +// Main entry point: applies passthrough or static headers based on configuration. +func ApplyHeaderOverrides(req *http.Request, cfg HeaderOverridesConfig, userPathAlias string) { + // No-op if neither passthrough nor custom headers are configured + if !cfg.PassthroughUserHeaders && len(cfg.CustomUpstreamHeaders) == 0 { + return + } + + if cfg.PassthroughUserHeaders { + // Log if custom headers ignored due to passthrough + if len(cfg.CustomUpstreamHeaders) > 0 { + slog.Debug("custom_upstream_headers ignored because passthrough_user_headers is active") + } + applyPassthroughHeaders(req, cfg, userPathAlias) + } else if len(cfg.CustomUpstreamHeaders) > 0 { + applyStaticHeaders(req, cfg.CustomUpstreamHeaders, userPathAlias) + } +} + +// applyPassthroughHeaders reads headers from request context, applies skip/allow list, sets headers. +func applyPassthroughHeaders(req *http.Request, cfg HeaderOverridesConfig, userPathAlias string) { + source := PassthroughHeadersFromContext(req.Context()) + if source == nil { + return + } + + skipSet := normalizeHeaderSet(cfg.SkipHeaders) + + for name, values := range source { + if shouldForward(name, skipSet, cfg.SkipMode, userPathAlias) { + // Del first so forwarded values overwrite any provider defaults already on req.Header. + req.Header.Del(name) + for _, v := range values { + req.Header.Add(name, v) + } + } + } +} + +// applyStaticHeaders adds static custom headers to request, skipping blocked names. +func applyStaticHeaders(req *http.Request, headers map[string]string, userPathAlias string) { + for name, value := range headers { + if !IsHeaderBlocked(name, userPathAlias) { + req.Header.Set(name, value) + } + } +} + +// shouldForward reports whether header should be forwarded based on skip/allow configuration. +// Checks floor (hard-coded blocks) first, then applies skip/allow list based on mode. +func shouldForward(name string, skipSet map[string]bool, mode string, userPathAlias string) bool { + // Hard-coded floor: check blocked headers first + if IsHeaderBlocked(name, userPathAlias) { + return false + } + + // Apply skip/allow list based on mode + lower := strings.ToLower(strings.TrimSpace(name)) + switch mode { + case "allow", "only": + // Allow/only mode: only forward if header is in skipSet + return skipSet[lower] + case "skip", "": + // Skip mode: forward if header is NOT in skipSet + return !skipSet[lower] + default: + // Unknown mode: conservative default to skip + return !skipSet[lower] + } +} + +// normalizeHeaderSet converts header list to case-insensitive lookup set. +func normalizeHeaderSet(headers []string) map[string]bool { + result := make(map[string]bool) + for _, h := range headers { + lower := strings.ToLower(strings.TrimSpace(h)) + result[lower] = true + } + return result +} diff --git a/internal/providers/init.go b/internal/providers/init.go index 90c79141..3249d242 100644 --- a/internal/providers/init.go +++ b/internal/providers/init.go @@ -27,6 +27,12 @@ type InitResult struct { // by configured provider name. ConfiguredProviders []SanitizedProviderConfig + // ResolvedProviders is the fully merged provider configuration map + // (env + resilience + credentials) keyed by configured provider name. + // Use this to inspect resolved provider flags (e.g. PassthroughUserHeaders) + // without re-parsing raw YAML. + ResolvedProviders map[string]ProviderConfig + // CredentialResolvedProviders is the env-merged, credential-filtered providers // map (same keys as Router). Keys match top-level providers YAML names. CredentialResolvedProviders map[string]config.RawProviderConfig @@ -155,8 +161,13 @@ func Init(ctx context.Context, result *config.LoadResult, factory *ProviderFacto return nil, fmt.Errorf("failed to create router: %w", err) } + resolved := make(map[string]ProviderConfig, len(providerMap)) + for name, cfg := range providerMap { + resolved[name] = cfg + } return &InitResult{ ConfiguredProviders: SanitizeProviderConfigs(providerMap), + ResolvedProviders: resolved, Registry: registry, Router: router, Cache: modelCache, diff --git a/internal/providers/ollama/ollama.go b/internal/providers/ollama/ollama.go index 3fd6436f..5aebec60 100644 --- a/internal/providers/ollama/ollama.go +++ b/internal/providers/ollama/ollama.go @@ -35,14 +35,20 @@ const ( // Provider implements the core.Provider interface for Ollama type Provider struct { - client *llmclient.Client - nativeClient *llmclient.Client - apiKey string // Accepted but ignored by Ollama + client *llmclient.Client + nativeClient *llmclient.Client + apiKey string // Accepted but ignored by Ollama + headerOverrides *providers.HeaderOverridesConfig + userPathAlias string } // New creates a new Ollama provider. func New(providerCfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { - p := &Provider{apiKey: providerCfg.APIKey} + p := &Provider{ + apiKey: providerCfg.APIKey, + headerOverrides: opts.HeaderOverrides, + userPathAlias: opts.UserPathHeader, + } clientCfg := llmclient.Config{ ProviderName: "ollama", BaseURL: defaultBaseURL, @@ -65,12 +71,17 @@ func New(providerCfg providers.ProviderConfig, opts providers.ProviderOptions) c } // NewWithHTTPClient creates a new Ollama provider with a custom HTTP client. -// If httpClient is nil, http.DefaultClient is used. -func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { +// If httpClient is nil, http.DefaultClient is used. headerOverrides and +// userPathAlias are optional; pass nil and "" to disable header overrides. +func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.Hooks, headerOverrides *providers.HeaderOverridesConfig, userPathAlias string) *Provider { if httpClient == nil { httpClient = http.DefaultClient } - p := &Provider{apiKey: apiKey} + p := &Provider{ + apiKey: apiKey, + headerOverrides: headerOverrides, + userPathAlias: userPathAlias, + } cfg := llmclient.DefaultConfig("ollama", defaultBaseURL) cfg.Hooks = hooks p.client = llmclient.NewWithHTTPClient(httpClient, cfg, p.setHeaders) @@ -100,7 +111,9 @@ func (p *Provider) CheckAvailability(ctx context.Context) error { return err } -// setHeaders sets the required headers for Ollama API requests +// setHeaders sets the required headers for Ollama API requests. Applied to +// both the OpenAI-compatible client and the native /api/embed client because +// they share provider configuration. func (p *Provider) setHeaders(req *http.Request) { // Ollama doesn't require authentication, but accepts a Bearer token if provided. providers.SetAuthHeaders(req, p.apiKey, providers.AuthHeaderConfig{ @@ -108,6 +121,9 @@ func (p *Provider) setHeaders(req *http.Request) { RequestIDHeader: "X-Request-ID", OptionalAPIKey: true, }) + if p.headerOverrides != nil { + providers.ApplyHeaderOverrides(req, *p.headerOverrides, p.userPathAlias) + } } // ChatCompletion sends a chat completion request to Ollama diff --git a/internal/providers/openai/compatible_provider.go b/internal/providers/openai/compatible_provider.go index e01272e9..5cc80166 100644 --- a/internal/providers/openai/compatible_provider.go +++ b/internal/providers/openai/compatible_provider.go @@ -20,6 +20,8 @@ type CompatibleProviderConfig struct { BaseURL string SetHeaders func(*http.Request, string) RequestMutator RequestMutator + HeaderOverrides *providers.HeaderOverridesConfig + UserPathAlias string } type CompatibleProvider struct { @@ -30,6 +32,15 @@ type CompatibleProvider struct { } func NewCompatibleProvider(apiKey string, opts providers.ProviderOptions, cfg CompatibleProviderConfig) *CompatibleProvider { + // Auto-inject HeaderOverrides from ProviderOptions + if cfg.HeaderOverrides == nil && opts.HeaderOverrides != nil { + cfg.HeaderOverrides = opts.HeaderOverrides + } + // Auto-inject UserPathAlias from ProviderOptions + if cfg.UserPathAlias == "" && opts.UserPathHeader != "" { + cfg.UserPathAlias = opts.UserPathHeader + } + p := &CompatibleProvider{ apiKey: apiKey, providerName: cfg.ProviderName, @@ -46,6 +57,9 @@ func NewCompatibleProvider(apiKey string, opts providers.ProviderOptions, cfg Co if cfg.SetHeaders != nil { cfg.SetHeaders(req, apiKey) } + if cfg.HeaderOverrides != nil { + providers.ApplyHeaderOverrides(req, *cfg.HeaderOverrides, cfg.UserPathAlias) + } }) return p } @@ -65,6 +79,9 @@ func NewCompatibleProviderWithHTTPClient(apiKey string, httpClient *http.Client, if cfg.SetHeaders != nil { cfg.SetHeaders(req, apiKey) } + if cfg.HeaderOverrides != nil { + providers.ApplyHeaderOverrides(req, *cfg.HeaderOverrides, cfg.UserPathAlias) + } }) return p } diff --git a/internal/providers/vllm/vllm.go b/internal/providers/vllm/vllm.go index 658f82f0..e03d7b05 100644 --- a/internal/providers/vllm/vllm.go +++ b/internal/providers/vllm/vllm.go @@ -28,8 +28,10 @@ var Registration = providers.Registration{ // Provider implements the core.Provider interface for vLLM. type Provider struct { - compatible *openai.CompatibleProvider - rootClient *llmclient.Client + compatible *openai.CompatibleProvider + rootClient *llmclient.Client + headerOverrides *providers.HeaderOverridesConfig + userPathAlias string } // New creates a new vLLM provider. @@ -42,6 +44,8 @@ func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Prov BaseURL: baseURL, SetHeaders: setHeaders, }), + headerOverrides: opts.HeaderOverrides, + userPathAlias: opts.UserPathHeader, rootClient: llmclient.New(llmclient.Config{ ProviderName: "vllm", BaseURL: rootBaseURL, @@ -49,14 +53,16 @@ func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Prov Hooks: opts.Hooks, CircuitBreaker: opts.Resilience.CircuitBreaker, }, func(req *http.Request) { - setHeaders(req, cfg.APIKey) + setRootHeaders(req, cfg.APIKey, opts.HeaderOverrides, opts.UserPathHeader) }), } } // NewWithHTTPClient creates a new vLLM provider with a custom HTTP client. -// If httpClient is nil, http.DefaultClient is used. -func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { +// If httpClient is nil, http.DefaultClient is used. headerOverrides and +// userPathAlias are optional; pass nil and "" to disable header overrides on +// the non-v1 passthrough client. +func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks, headerOverrides *providers.HeaderOverridesConfig, userPathAlias string) *Provider { resolvedBaseURL := providers.ResolveBaseURL(baseURL, defaultBaseURL) rootClientCfg := llmclient.DefaultConfig("vllm", passthroughBaseURL(resolvedBaseURL)) rootClientCfg.Hooks = hooks @@ -66,8 +72,10 @@ func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, h BaseURL: resolvedBaseURL, SetHeaders: setHeaders, }), + headerOverrides: headerOverrides, + userPathAlias: userPathAlias, rootClient: llmclient.NewWithHTTPClient(httpClient, rootClientCfg, func(req *http.Request) { - setHeaders(req, apiKey) + setRootHeaders(req, apiKey, headerOverrides, userPathAlias) }), } } @@ -78,6 +86,8 @@ func (p *Provider) SetBaseURL(url string) { p.rootClient.SetBaseURL(passthroughBaseURL(url)) } +// setHeaders is used for OpenAI-compatible (v1) requests. Header overrides are +// auto-applied by CompatibleProvider from its config, so we only set auth here. func setHeaders(req *http.Request, apiKey string) { providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{ AuthScheme: "Bearer ", @@ -86,6 +96,20 @@ func setHeaders(req *http.Request, apiKey string) { }) } +// setRootHeaders is used for non-v1 (root) passthrough requests. Auth is set +// first so static custom headers can't override it; passthrough user headers +// are then applied with the same blocked-header floor as elsewhere. +func setRootHeaders(req *http.Request, apiKey string, headerOverrides *providers.HeaderOverridesConfig, userPathAlias string) { + providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{ + AuthScheme: "Bearer ", + RequestIDHeader: "X-Request-Id", + OptionalAPIKey: true, + }) + if headerOverrides != nil { + providers.ApplyHeaderOverrides(req, *headerOverrides, userPathAlias) + } +} + // ChatCompletion sends a chat completion request to vLLM. func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { return p.compatible.ChatCompletion(ctx, req) diff --git a/internal/providers/xai/xai.go b/internal/providers/xai/xai.go index e6dd5f50..930a9fbc 100644 --- a/internal/providers/xai/xai.go +++ b/internal/providers/xai/xai.go @@ -33,13 +33,19 @@ const ( // Provider implements the core.Provider interface for xAI type Provider struct { - client *llmclient.Client - apiKey string + client *llmclient.Client + apiKey string + headerOverrides *providers.HeaderOverridesConfig + userPathAlias string } // New creates a new xAI provider. func New(providerCfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { - p := &Provider{apiKey: providerCfg.APIKey} + p := &Provider{ + apiKey: providerCfg.APIKey, + headerOverrides: opts.HeaderOverrides, + userPathAlias: opts.UserPathHeader, + } clientCfg := llmclient.Config{ ProviderName: "xai", BaseURL: providers.ResolveBaseURL(providerCfg.BaseURL, defaultBaseURL), @@ -52,12 +58,17 @@ func New(providerCfg providers.ProviderConfig, opts providers.ProviderOptions) c } // NewWithHTTPClient creates a new xAI provider with a custom HTTP client. -// If httpClient is nil, http.DefaultClient is used. -func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { +// If httpClient is nil, http.DefaultClient is used. headerOverrides and +// userPathAlias are optional; pass nil and "" to disable header overrides. +func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.Hooks, headerOverrides *providers.HeaderOverridesConfig, userPathAlias string) *Provider { if httpClient == nil { httpClient = http.DefaultClient } - p := &Provider{apiKey: apiKey} + p := &Provider{ + apiKey: apiKey, + headerOverrides: headerOverrides, + userPathAlias: userPathAlias, + } cfg := llmclient.DefaultConfig("xai", defaultBaseURL) cfg.Hooks = hooks p.client = llmclient.NewWithHTTPClient(httpClient, cfg, p.setHeaders) @@ -75,6 +86,9 @@ func (p *Provider) setHeaders(req *http.Request) { AuthScheme: "Bearer ", RequestIDHeader: "X-Request-ID", }) + if p.headerOverrides != nil { + providers.ApplyHeaderOverrides(req, *p.headerOverrides, p.userPathAlias) + } } type grokConversationAnchor struct { diff --git a/internal/server/http.go b/internal/server/http.go index 22d834b8..1fc2b942 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -78,6 +78,7 @@ type Config struct { EnabledPassthroughProviders []string // Provider types enabled on /p/{provider}/... passthrough routes AllowPassthroughV1Alias *bool // Allow /p/{provider}/v1/... aliases; nil defaults to true UserPathHeader string // Header carrying the request user path (default: X-GoModel-User-Path) + PassthroughUserHeadersEnabled bool // At least one configured provider has passthrough_user_headers enabled; gates registration of PassthroughHeaderCapture AdminEndpointsEnabled bool // Whether admin API endpoints are enabled AdminUIEnabled bool // Whether admin dashboard UI is enabled AdminHandler *admin.Handler // Admin API handler (nil if disabled) @@ -280,6 +281,13 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { userPathHeaderName := configuredUserPathHeader(cfg) e.Use(RequestSnapshotCapture(userPathHeaderName)) + // Capture passthrough-eligible headers as early as possible so any provider + // downstream sees the original request shape. Registered only when at least + // one provider opted in to keep the hot path zero-cost otherwise. + if cfg != nil && cfg.PassthroughUserHeadersEnabled { + e.Use(PassthroughHeaderCapture(userPathHeaderName)) + } + // Request labelling from configured tagging headers (after snapshot capture so // audit logging still sees the original headers, before audit logging so // entries can record the labels) diff --git a/internal/server/passthrough_headers.go b/internal/server/passthrough_headers.go new file mode 100644 index 00000000..b52d2375 --- /dev/null +++ b/internal/server/passthrough_headers.go @@ -0,0 +1,27 @@ +package server + +import ( + "github.com/labstack/echo/v5" + + "gomodel/internal/providers" +) + +// PassthroughHeaderCapture returns an Echo middleware that captures the incoming +// request headers, filters them against the hard-coded skip floor (credential +// headers and user-path headers), and stores the filtered map in the request +// context for downstream providers. +// +// The original request headers are never mutated; only the context is enriched. +// If userPathAlias is empty, only the hard-coded credential/user-path floor is +// applied. +func PassthroughHeaderCapture(userPathAlias string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + filtered := providers.FilterIncomingHeaders(req.Header, userPathAlias) + ctx := providers.WithPassthroughHeaders(req.Context(), filtered) + c.SetRequest(req.WithContext(ctx)) + return next(c) + } + } +} \ No newline at end of file From 3efecb34e0daa33548a6752e1af52259c7540cca Mon Sep 17 00:00:00 2001 From: weselben Date: Sun, 5 Jul 2026 00:06:45 +0200 Subject: [PATCH 3/5] tests(headers): add unit and provider tests for header overrides Cover config parsing, factory wiring, core header override logic, server middleware capture, and provider-specific application across OpenAI-compatible providers. --- config/providers_test.go | 109 +++ internal/app/app_test.go | 28 + internal/providers/config_test.go | 141 +++ internal/providers/deepseek/deepseek_test.go | 295 ++++++- internal/providers/factory_test.go | 153 ++++ internal/providers/groq/groq_test.go | 78 ++ internal/providers/headers_test.go | 827 ++++++++++++++++++ internal/providers/ollama/ollama_test.go | 334 ++++++- .../openai/compatible_provider_test.go | 171 ++++ internal/providers/vllm/vllm_test.go | 319 ++++++- internal/providers/xai/xai_test.go | 293 ++++++- internal/server/http_test.go | 125 +++ internal/server/passthrough_headers_test.go | 160 ++++ tests/contract/xai_test.go | 2 +- 14 files changed, 2982 insertions(+), 53 deletions(-) create mode 100644 config/providers_test.go create mode 100644 internal/providers/headers_test.go create mode 100644 internal/server/passthrough_headers_test.go diff --git a/config/providers_test.go b/config/providers_test.go new file mode 100644 index 00000000..dc81a8fc --- /dev/null +++ b/config/providers_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +// TestRawProviderConfig_HeaderYAMLTags verifies that the 4 new fields +// (custom_upstream_headers, passthrough_user_headers, passthrough_user_headers_skip, +// passthrough_user_headers_skip_mode) are correctly populated when a YAML provider +// block containing them is unmarshalled into RawProviderConfig. +func TestRawProviderConfig_HeaderYAMLTags(t *testing.T) { + yamlData := ` +provider: + type: openai + api_key: sk-test + custom_upstream_headers: + X-Custom-Header: custom-value + X-Another-Header: another-value + passthrough_user_headers: true + passthrough_user_headers_skip: + - Authorization + - Cookie + passthrough_user_headers_skip_mode: only +` + + var config struct { + Provider RawProviderConfig `yaml:"provider"` + } + + if err := yaml.Unmarshal([]byte(yamlData), &config); err != nil { + t.Fatalf("yaml.Unmarshal failed: %v", err) + } + + // Verify CustomUpstreamHeaders + if config.Provider.CustomUpstreamHeaders == nil { + t.Error("CustomUpstreamHeaders should not be nil") + } else { + if got, want := len(config.Provider.CustomUpstreamHeaders), 2; got != want { + t.Errorf("CustomUpstreamHeaders length: got %d, want %d", got, want) + } + if got, want := config.Provider.CustomUpstreamHeaders["X-Custom-Header"], "custom-value"; got != want { + t.Errorf("CustomUpstreamHeaders[X-Custom-Header]: got %q, want %q", got, want) + } + if got, want := config.Provider.CustomUpstreamHeaders["X-Another-Header"], "another-value"; got != want { + t.Errorf("CustomUpstreamHeaders[X-Another-Header]: got %q, want %q", got, want) + } + } + + // Verify PassthroughUserHeaders + if got, want := config.Provider.PassthroughUserHeaders, true; got != want { + t.Errorf("PassthroughUserHeaders: got %v, want %v", got, want) + } + + // Verify PassthroughUserHeadersSkip + if got, want := len(config.Provider.PassthroughUserHeadersSkip), 2; got != want { + t.Errorf("PassthroughUserHeadersSkip length: got %d, want %d", got, want) + } + if got, want := config.Provider.PassthroughUserHeadersSkip[0], "Authorization"; got != want { + t.Errorf("PassthroughUserHeadersSkip[0]: got %q, want %q", got, want) + } + if got, want := config.Provider.PassthroughUserHeadersSkip[1], "Cookie"; got != want { + t.Errorf("PassthroughUserHeadersSkip[1]: got %q, want %q", got, want) + } + + // Verify PassthroughUserHeadersSkipMode + if got, want := config.Provider.PassthroughUserHeadersSkipMode, "only"; got != want { + t.Errorf("PassthroughUserHeadersSkipMode: got %q, want %q", got, want) + } +} + +// TestRawProviderConfig_HeaderYAMLTags_Empty verifies that header fields +// default to empty/nil when not present in YAML. +func TestRawProviderConfig_HeaderYAMLTags_Empty(t *testing.T) { + yamlData := ` +provider: + type: openai + api_key: sk-test +` + + var config struct { + Provider RawProviderConfig `yaml:"provider"` + } + + if err := yaml.Unmarshal([]byte(yamlData), &config); err != nil { + t.Fatalf("yaml.Unmarshal failed: %v", err) + } + + // Verify CustomUpstreamHeaders is nil + if config.Provider.CustomUpstreamHeaders != nil { + t.Errorf("CustomUpstreamHeaders should be nil when not specified, got %v", config.Provider.CustomUpstreamHeaders) + } + + // Verify PassthroughUserHeaders defaults to false + if got, want := config.Provider.PassthroughUserHeaders, false; got != want { + t.Errorf("PassthroughUserHeaders: got %v, want %v", got, want) + } + + // Verify PassthroughUserHeadersSkip is nil + if config.Provider.PassthroughUserHeadersSkip != nil { + t.Errorf("PassthroughUserHeadersSkip should be nil when not specified, got %v", config.Provider.PassthroughUserHeadersSkip) + } + + // Verify PassthroughUserHeadersSkipMode is empty string + if got, want := config.Provider.PassthroughUserHeadersSkipMode, ""; got != want { + t.Errorf("PassthroughUserHeadersSkipMode: got %q, want %q", got, want) + } +} \ No newline at end of file diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 58d9ef3a..025ef886 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -646,4 +646,32 @@ func (r *staticRewriter) Name() string { return r.name } func (r *staticRewriter) Rewrite(context.Context, ext.Input) (*ext.Result, error) { return nil, nil +func TestAnyProviderHasPassthroughUserHeaders_True(t *testing.T) { + resolved := map[string]providers.ProviderConfig{ + "openai": {Type: "openai", PassthroughUserHeaders: false}, + "anthropic": {Type: "anthropic", PassthroughUserHeaders: true}, + "groq": {Type: "groq", PassthroughUserHeaders: false}, + } + if !anyProviderHasPassthroughUserHeaders(resolved) { + t.Fatal("expected true when at least one provider has passthrough_user_headers=true") + } +} + +func TestAnyProviderHasPassthroughUserHeaders_False(t *testing.T) { + resolved := map[string]providers.ProviderConfig{ + "openai": {Type: "openai", PassthroughUserHeaders: false}, + "anthropic": {Type: "anthropic", PassthroughUserHeaders: false}, + } + if anyProviderHasPassthroughUserHeaders(resolved) { + t.Fatal("expected false when no provider has passthrough_user_headers=true") + } +} + +func TestAnyProviderHasPassthroughUserHeaders_EmptyMap(t *testing.T) { + if anyProviderHasPassthroughUserHeaders(nil) { + t.Fatal("expected false for nil resolved provider map") + } + if anyProviderHasPassthroughUserHeaders(map[string]providers.ProviderConfig{}) { + t.Fatal("expected false for empty resolved provider map") + } } diff --git a/internal/providers/config_test.go b/internal/providers/config_test.go index 74ecc01d..7d4c41dc 100644 --- a/internal/providers/config_test.go +++ b/internal/providers/config_test.go @@ -61,6 +61,9 @@ var testDiscoveryConfigs = map[string]DiscoveryConfig{ DefaultBaseURL: "http://localhost:11434/v1", AllowAPIKeyless: true, }, + "kimi": { + DefaultBaseURL: "https://api.kimi.com/coding/v1", + }, } // --- buildProviderConfig --- @@ -86,6 +89,65 @@ func TestBuildProviderConfig_NilResilience(t *testing.T) { } } +func TestBuildProviderConfig_HeaderFields(t *testing.T) { + raw := config.RawProviderConfig{ + Type: "openai", + APIKey: "sk-test", + CustomUpstreamHeaders: map[string]string{"X-Custom": "value"}, + PassthroughUserHeaders: true, + PassthroughUserHeadersSkip: []string{"Authorization", "Cookie"}, + PassthroughUserHeadersSkipMode: "only", + } + got := buildProviderConfig(raw, globalResilience) + + // Verify CustomUpstreamHeaders is copied + if got.CustomUpstreamHeaders == nil { + t.Error("CustomUpstreamHeaders should not be nil") + } else { + if got, want := len(got.CustomUpstreamHeaders), 1; got != want { + t.Errorf("CustomUpstreamHeaders length: got %d, want %d", got, want) + } + if got, want := got.CustomUpstreamHeaders["X-Custom"], "value"; got != want { + t.Errorf("CustomUpstreamHeaders[X-Custom]: got %q, want %q", got, want) + } + } + + // Verify PassthroughUserHeaders is copied + if got, want := got.PassthroughUserHeaders, true; got != want { + t.Errorf("PassthroughUserHeaders: got %v, want %v", got, want) + } + + // Verify PassthroughUserHeadersSkip is copied + if got, want := len(got.PassthroughUserHeadersSkip), 2; got != want { + t.Errorf("PassthroughUserHeadersSkip length: got %d, want %d", got, want) + } + if got, want := got.PassthroughUserHeadersSkip[0], "Authorization"; got != want { + t.Errorf("PassthroughUserHeadersSkip[0]: got %q, want %q", got, want) + } + if got, want := got.PassthroughUserHeadersSkip[1], "Cookie"; got != want { + t.Errorf("PassthroughUserHeadersSkip[1]: got %q, want %q", got, want) + } + + // Verify PassthroughUserHeadersSkipMode is copied + if got, want := got.PassthroughUserHeadersSkipMode, "only"; got != want { + t.Errorf("PassthroughUserHeadersSkipMode: got %q, want %q", got, want) + } +} + +func TestBuildProviderConfig_HeaderFields_DefaultSkipMode(t *testing.T) { + raw := config.RawProviderConfig{ + Type: "openai", + APIKey: "sk-test", + PassthroughUserHeadersSkipMode: "", + } + got := buildProviderConfig(raw, globalResilience) + + // Verify PassthroughUserHeadersSkipMode defaults to "skip" when empty + if got, want := got.PassthroughUserHeadersSkipMode, "skip"; got != want { + t.Errorf("PassthroughUserHeadersSkipMode: got %q, want %q", got, want) + } +} + func TestBuildProviderConfig_NilRetry(t *testing.T) { raw := config.RawProviderConfig{ Type: "openai", @@ -1525,3 +1587,82 @@ func TestResolveProviders_NoProvidersNoEnvVars(t *testing.T) { t.Errorf("expected empty filtered raw, got %d entries", len(filteredRaw)) } } + +// --- applyProviderEnvVars: per-provider PASSTHROUGH_USER_HEADERS overlay --- + +// TestApplyProviderEnvVars_Passthrough_FromAPIKeyAndFlag covers the discovery +// path: a Kimi API key plus the passthrough env var must produce a resolved +// provider with PassthroughUserHeaders enabled. +func TestApplyProviderEnvVars_Passthrough_FromAPIKeyAndFlag(t *testing.T) { + t.Setenv("KIMI_API_KEY", "sk-test") + t.Setenv("KIMI_PASSTHROUGH_USER_HEADERS", "true") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + p, exists := got["kimi"] + if !exists { + t.Fatalf("expected kimi provider to be discovered from env vars, got %d entries", len(got)) + } + if p.APIKey != "sk-test" { + t.Errorf("kimi APIKey = %q, want sk-test", p.APIKey) + } + if !p.PassthroughUserHeaders { + t.Errorf("kimi PassthroughUserHeaders = false, want true") + } +} + +// TestApplyProviderEnvVars_Passthrough_EnvWinsOverYAMLDisabled verifies the +// overlay semantics: a YAML value of false for passthrough_user_headers must be +// overridden when the env var is truthy. +func TestApplyProviderEnvVars_Passthrough_EnvWinsOverYAMLDisabled(t *testing.T) { + t.Setenv("KIMI_API_KEY", "sk-yaml-env") + t.Setenv("KIMI_PASSTHROUGH_USER_HEADERS", "true") + + raw := map[string]config.RawProviderConfig{ + "kimi": { + Type: "kimi", + APIKey: "sk-yaml", + PassthroughUserHeaders: false, + }, + } + + got := applyProviderEnvVars(raw, testDiscoveryConfigs) + + p, exists := got["kimi"] + if !exists { + t.Fatal("expected kimi provider to remain after env overlay") + } + if !p.PassthroughUserHeaders { + t.Errorf("kimi PassthroughUserHeaders = false, want true (env should win over YAML)") + } +} + +// TestApplyProviderEnvVars_Passthrough_AloneDoesNotDiscover guards the +// contract that a passthrough env var alone is not enough to bootstrap a +// provider — empty() intentionally omits PassthroughUserHeaders. +func TestApplyProviderEnvVars_Passthrough_AloneDoesNotDiscover(t *testing.T) { + t.Setenv("KIMI_PASSTHROUGH_USER_HEADERS", "true") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + if _, exists := got["kimi"]; exists { + t.Errorf("expected no kimi provider discovered from passthrough env var alone, got %+v", got["kimi"]) + } +} + +// TestApplyProviderEnvVars_Passthrough_FalseDoesNotEnable verifies that an +// explicit false (or any non-truthy token) leaves PassthroughUserHeaders off. +func TestApplyProviderEnvVars_Passthrough_FalseDoesNotEnable(t *testing.T) { + t.Setenv("KIMI_API_KEY", "sk-test") + t.Setenv("KIMI_PASSTHROUGH_USER_HEADERS", "false") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + p, exists := got["kimi"] + if !exists { + t.Fatalf("expected kimi provider to be discovered from API key, got %d entries", len(got)) + } + if p.PassthroughUserHeaders { + t.Errorf("kimi PassthroughUserHeaders = true, want false") + } +} diff --git a/internal/providers/deepseek/deepseek_test.go b/internal/providers/deepseek/deepseek_test.go index ddaa6c28..056017cd 100644 --- a/internal/providers/deepseek/deepseek_test.go +++ b/internal/providers/deepseek/deepseek_test.go @@ -11,6 +11,7 @@ import ( "gomodel/internal/core" "gomodel/internal/llmclient" + "gomodel/internal/providers" ) func TestChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { @@ -30,7 +31,7 @@ func TestChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ Model: "deepseek-v4-pro", @@ -70,7 +71,7 @@ func TestChatCompletion_MapsReasoningToDeepSeekReasoningEffort(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ Model: "deepseek-v4-pro", @@ -109,7 +110,7 @@ func TestResponses_TranslatesToChatCompletions(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") maxOutputTokens := 64 resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ @@ -168,7 +169,7 @@ func TestStreamResponses_TranslatesToChatCompletions(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ Model: "deepseek-v4-pro", @@ -214,7 +215,7 @@ func TestNormalizeReasoningEffort(t *testing.T) { } func TestProvider_DoesNotExposeOptionalNativeInterfaces(t *testing.T) { - provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}, nil, "") if _, ok := any(provider).(core.NativeBatchProvider); ok { t.Fatal("deepseek provider should not implement native batch provider") @@ -241,7 +242,7 @@ func TestPassthrough_ForwardsRequestWithBearerAuth(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") body := strings.NewReader(`{"model":"deepseek-v4-pro","prompt":"hello "}`) resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ @@ -271,7 +272,7 @@ func TestPassthrough_ForwardsRequestWithBearerAuth(t *testing.T) { } func TestPassthrough_NilRequest_ReturnsError(t *testing.T) { - provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}, nil, "") _, err := provider.Passthrough(context.Background(), nil) if err == nil { t.Fatal("expected error for nil passthrough request, got nil") @@ -288,7 +289,7 @@ func TestPassthrough_ForwardsRequestHeaders(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ Method: http.MethodPost, @@ -312,7 +313,7 @@ func TestPassthrough_PreservesNon2xxStatus(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ Method: http.MethodPost, @@ -338,7 +339,7 @@ func TestPassthrough_ForwardsQueryString(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ Method: http.MethodGet, @@ -363,7 +364,7 @@ func TestPassthrough_PreservesResponseBody(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ Method: http.MethodPost, @@ -384,12 +385,12 @@ func TestPassthrough_PreservesResponseBody(t *testing.T) { } func TestProvider_ImplementsPassthroughProvider(t *testing.T) { - provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}, nil, "") var _ core.PassthroughProvider = provider } func TestResponses_NilRequest_ReturnsError(t *testing.T) { - provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}, nil, "") _, err := provider.Responses(context.Background(), nil) if err == nil { t.Fatal("expected error for nil Responses request, got nil") @@ -397,7 +398,7 @@ func TestResponses_NilRequest_ReturnsError(t *testing.T) { } func TestStreamResponses_NilRequest_ReturnsError(t *testing.T) { - provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}, nil, "") _, err := provider.StreamResponses(context.Background(), nil) if err == nil { t.Fatal("expected error for nil StreamResponses request, got nil") @@ -405,10 +406,274 @@ func TestStreamResponses_NilRequest_ReturnsError(t *testing.T) { } func TestEmbeddings_ReturnsUnsupported(t *testing.T) { - provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}, nil, "") _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "embedding-model", Input: "hi"}) if err == nil { t.Fatal("expected unsupported embeddings error, got nil") } } + +func TestChatCompletion_AppliesStaticCustomHeaders(t *testing.T) { + var gotRegion, gotTrace string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRegion = r.Header.Get("X-Provider-Region") + gotTrace = r.Header.Get("X-Trace-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-deepseek", + "created":1677652288, + "model":"deepseek-v4-pro", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Region": "us-east-1", + "X-Trace-Id": "trace-abc", + }, + } + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, cfg, "") + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "deepseek-v4-pro", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotRegion != "us-east-1" { + t.Errorf("X-Provider-Region = %q, want us-east-1", gotRegion) + } + if gotTrace != "trace-abc" { + t.Errorf("X-Trace-Id = %q, want trace-abc", gotTrace) + } +} + +func TestChatCompletion_StaticHeaders_BlocksCredentialAndInternal(t *testing.T) { + var gotAuth, gotAPIKey, gotInternal, gotSafe string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotAPIKey = r.Header.Get("X-Api-Key") + gotInternal = r.Header.Get("X-GoModel-User-Path") + gotSafe = r.Header.Get("X-Safe") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-deepseek", + "created":1677652288, + "model":"deepseek-v4-pro", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "Authorization": "Bearer leaked", + "X-Api-Key": "leaked-key", + "X-GoModel-User-Path": "/internal", + "X-Safe": "ok", + }, + } + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, cfg, "") + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "deepseek-v4-pro", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + // Provider-set Authorization is still applied (setHeaders runs first). + if gotAuth != "Bearer deepseek-key" { + t.Errorf("Authorization = %q, want Bearer deepseek-key", gotAuth) + } + if gotAPIKey != "" { + t.Errorf("X-Api-Key = %q, want empty (blocked)", gotAPIKey) + } + if gotInternal != "" { + t.Errorf("X-GoModel-User-Path = %q, want empty (blocked)", gotInternal) + } + if gotSafe != "ok" { + t.Errorf("X-Safe = %q, want ok", gotSafe) + } +} + +func TestChatCompletion_StaticHeaders_RespectsUserPathAlias(t *testing.T) { + var gotAlias, gotOther string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAlias = r.Header.Get("X-My-Alias") + gotOther = r.Header.Get("X-Other") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-deepseek", + "created":1677652288, + "model":"deepseek-v4-pro", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-My-Alias": "secret", + "X-Other": "ok", + }, + } + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, cfg, "X-My-Alias") + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "deepseek-v4-pro", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotAlias != "" { + t.Errorf("X-My-Alias = %q, want empty (blocked by alias)", gotAlias) + } + if gotOther != "ok" { + t.Errorf("X-Other = %q, want ok", gotOther) + } +} + +func TestChatCompletion_PassthroughUserHeaders(t *testing.T) { + var gotCustom, gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCustom = r.Header.Get("X-User-Custom") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-deepseek", + "created":1677652288, + "model":"deepseek-v4-pro", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + PassthroughUserHeaders: true, + } + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, cfg, "X-GoModel-User-Path") + + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-User-Custom": {"user-value"}, + "X-Other-Pass": {"other-value"}, + "X-Skip-Me": {"nope"}, + "Authorization": {"Bearer leaked"}, + "X-GoModel-User-Path": {"/internal"}, + }) + + if _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "deepseek-v4-pro", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotCustom != "user-value" { + t.Errorf("X-User-Custom = %q, want user-value", gotCustom) + } + // Auth must be the provider's, not the leaked user one. + if gotAuth != "Bearer deepseek-key" { + t.Errorf("Authorization = %q, want Bearer deepseek-key", gotAuth) + } +} + +func TestChatCompletion_PassthroughHeaders_AppliesSkipList(t *testing.T) { + var gotKept, gotSkipped string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKept = r.Header.Get("X-Keep-Me") + gotSkipped = r.Header.Get("X-Skip-Me") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-deepseek", + "created":1677652288, + "model":"deepseek-v4-pro", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Skip-Me"}, + SkipMode: "skip", + } + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}, cfg, "") + + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-Keep-Me": {"keep"}, + "X-Skip-Me": {"drop"}, + }) + + if _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "deepseek-v4-pro", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotKept != "keep" { + t.Errorf("X-Keep-Me = %q, want keep", gotKept) + } + if gotSkipped != "" { + t.Errorf("X-Skip-Me = %q, want empty (skipped)", gotSkipped) + } +} + +func TestNew_FactoryWiringPassesHeaderOverridesAndUserPathAlias(t *testing.T) { + var gotCustom, gotAlias string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCustom = r.Header.Get("X-Provider-Custom") + gotAlias = r.Header.Get("X-GoModel-User-Path") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-deepseek", + "created":1677652288, + "model":"deepseek-v4-pro", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := providers.ProviderConfig{ + Type: "deepseek", + APIKey: "deepseek-key", + BaseURL: server.URL, + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Custom": "factory-value", + }, + } + opts := providers.ProviderOptions{ + UserPathHeader: "X-GoModel-User-Path", + HeaderOverrides: &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Custom": "factory-value", + }, + }, + } + + provider := New(cfg, opts) + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-GoModel-User-Path": {"/v1/chat/completions"}, + }) + + if _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "deepseek-v4-pro", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotCustom != "factory-value" { + t.Errorf("X-Provider-Custom = %q, want factory-value", gotCustom) + } + // X-GoModel-User-Path is hard-coded blocked; alias test ensures it doesn't leak. + if gotAlias != "" { + t.Errorf("X-GoModel-User-Path = %q, want empty (blocked)", gotAlias) + } +} diff --git a/internal/providers/factory_test.go b/internal/providers/factory_test.go index 3f3c17e7..ea550d79 100644 --- a/internal/providers/factory_test.go +++ b/internal/providers/factory_test.go @@ -413,3 +413,156 @@ func TestProviderFactory_Create_PassesConfiguredModels(t *testing.T) { t.Fatalf("receivedOpts.Models = %v, want [model-a model-b]", receivedOpts.Models) } } + +func TestProviderFactory_SetUserPathHeader(t *testing.T) { + factory := NewProviderFactory() + factory.SetUserPathHeader("X-User-Path") + + var receivedOpts ProviderOptions + factory.Add(Registration{ + Type: "test", + New: func(cfg ProviderConfig, opts ProviderOptions) core.Provider { + receivedOpts = opts + return &factoryMockProvider{} + }, + }) + + cfg := ProviderConfig{ + Type: "test", + APIKey: "test-key", + } + + _, err := factory.Create(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedOpts.UserPathHeader != "X-User-Path" { + t.Errorf("UserPathHeader = %q, want X-User-Path", receivedOpts.UserPathHeader) + } +} + +func TestProviderFactory_Create_PassesHeaderOverrides(t *testing.T) { + factory := NewProviderFactory() + factory.SetUserPathHeader("X-User-Path") + + var receivedOpts ProviderOptions + factory.Add(Registration{ + Type: "test", + New: func(cfg ProviderConfig, opts ProviderOptions) core.Provider { + receivedOpts = opts + return &factoryMockProvider{} + }, + }) + + tests := []struct { + name string + cfg ProviderConfig + expectUserPathHeader string + expectHeaderOverrides *HeaderOverridesConfig + }{ + { + name: "with all header config", + cfg: ProviderConfig{ + Type: "test", + APIKey: "test-key", + CustomUpstreamHeaders: map[string]string{"X-Custom": "value"}, + PassthroughUserHeaders: true, + PassthroughUserHeadersSkip: []string{"Authorization"}, + PassthroughUserHeadersSkipMode: "skip", + }, + expectUserPathHeader: "X-User-Path", + expectHeaderOverrides: &HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{"X-Custom": "value"}, + PassthroughUserHeaders: true, + SkipHeaders: []string{"Authorization"}, + SkipMode: "skip", + }, + }, + { + name: "with only custom headers", + cfg: ProviderConfig{ + Type: "test", + APIKey: "test-key", + CustomUpstreamHeaders: map[string]string{"X-Auth": "token"}, + }, + expectUserPathHeader: "X-User-Path", + expectHeaderOverrides: &HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{"X-Auth": "token"}, + }, + }, + { + name: "with zero header config", + cfg: ProviderConfig{ + Type: "test", + APIKey: "test-key", + }, + expectUserPathHeader: "X-User-Path", + expectHeaderOverrides: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := factory.Create(tt.cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedOpts.UserPathHeader != tt.expectUserPathHeader { + t.Errorf("UserPathHeader = %q, want %q", receivedOpts.UserPathHeader, tt.expectUserPathHeader) + } + + if tt.expectHeaderOverrides == nil { + if receivedOpts.HeaderOverrides != nil { + t.Errorf("HeaderOverrides = %+v, want nil", receivedOpts.HeaderOverrides) + } + return + } + + if receivedOpts.HeaderOverrides == nil { + t.Fatal("HeaderOverrides = nil, want non-nil config") + } + + if !mapsEqual(receivedOpts.HeaderOverrides.CustomUpstreamHeaders, tt.expectHeaderOverrides.CustomUpstreamHeaders) { + t.Errorf("CustomUpstreamHeaders = %v, want %v", receivedOpts.HeaderOverrides.CustomUpstreamHeaders, tt.expectHeaderOverrides.CustomUpstreamHeaders) + } + + if receivedOpts.HeaderOverrides.PassthroughUserHeaders != tt.expectHeaderOverrides.PassthroughUserHeaders { + t.Errorf("PassthroughUserHeaders = %v, want %v", receivedOpts.HeaderOverrides.PassthroughUserHeaders, tt.expectHeaderOverrides.PassthroughUserHeaders) + } + + if !sliceEqual(receivedOpts.HeaderOverrides.SkipHeaders, tt.expectHeaderOverrides.SkipHeaders) { + t.Errorf("SkipHeaders = %v, want %v", receivedOpts.HeaderOverrides.SkipHeaders, tt.expectHeaderOverrides.SkipHeaders) + } + + if receivedOpts.HeaderOverrides.SkipMode != tt.expectHeaderOverrides.SkipMode { + t.Errorf("SkipMode = %q, want %q", receivedOpts.HeaderOverrides.SkipMode, tt.expectHeaderOverrides.SkipMode) + } + }) + } +} + +func mapsEqual(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func sliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/providers/groq/groq_test.go b/internal/providers/groq/groq_test.go index 32df4fbf..80a86961 100644 --- a/internal/providers/groq/groq_test.go +++ b/internal/providers/groq/groq_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "gomodel/internal/core" @@ -921,3 +922,80 @@ func TestCreateTranscription_UpstreamError(t *testing.T) { t.Errorf("error = %v, want upstream message propagated", err) } } + +// TestGroq_CustomUpstreamHeaders_AppliedToOutbound proves that +// HeaderOverrides configured via ProviderOptions flow through the Groq +// provider and land on the outgoing HTTP request. This exercises the same +// CompatibleProvider code path that the provider factory uses in production. +func TestGroq_CustomUpstreamHeaders_AppliedToOutbound(t *testing.T) { + var captured atomic.Pointer[http.Header] + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Clone() + captured.Store(&h) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"llama-3.3-70b-versatile","object":"model","owned_by":"groq"}]}`)) + })) + defer server.Close() + + opts := providers.ProviderOptions{ + HeaderOverrides: &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + "X-Trace-Id": "abc-123", + "Authorization": "should-be-blocked", // hard-coded credential block + "X-GoModel-User-Path": "should-be-blocked", // hard-coded internal block + }, + }, + UserPathHeader: "X-User-Path", + } + + // Use the factory entry point (New) so the test exercises the same + // ProviderOptions → CompatibleProvider path the production factory uses. + // NewWithHTTPClient does not accept ProviderOptions by design, so we + // type-assert to *Provider to call SetBaseURL on the concrete struct. + p := New(providers.ProviderConfig{ + Type: "groq", + APIKey: "gsk-test-key", + }, opts) + provider, ok := p.(*Provider) + if !ok { + t.Fatalf("New() returned %T, want *Provider", p) + } + provider.SetBaseURL(server.URL) + + if _, err := provider.ListModels(context.Background()); err != nil { + t.Fatalf("ListModels() error = %v", err) + } + + got := captured.Load() + if got == nil { + t.Fatal("server did not receive a request") + } + headers := *got + + if v := headers.Get("X-Custom-Header"); v != "custom-value" { + t.Errorf("X-Custom-Header = %q, want custom-value", v) + } + if v := headers.Get("X-Another-Header"); v != "another-value" { + t.Errorf("X-Another-Header = %q, want another-value", v) + } + if v := headers.Get("X-Trace-Id"); v != "abc-123" { + t.Errorf("X-Trace-Id = %q, want abc-123", v) + } + if v := headers.Get("X-User-Path"); v != "" { + t.Errorf("X-User-Path = %q, want empty (blocked by alias)", v) + } + if v := headers.Get("X-GoModel-User-Path"); v != "" { + t.Errorf("X-GoModel-User-Path = %q, want empty (hard-coded block)", v) + } + // Authorization: only the Bearer set by setHeaders should be present. + for _, v := range headers.Values("Authorization") { + if strings.HasPrefix(v, "should-be-blocked") { + t.Errorf("Authorization leaked custom value %q", v) + } + } + if v := headers.Get("Authorization"); !strings.HasPrefix(v, "Bearer ") { + t.Errorf("Authorization = %q, want Bearer prefix", v) + } +} diff --git a/internal/providers/headers_test.go b/internal/providers/headers_test.go new file mode 100644 index 00000000..d72d784b --- /dev/null +++ b/internal/providers/headers_test.go @@ -0,0 +1,827 @@ +package providers + +import ( + "bytes" + "context" + "log/slog" + "net/http" + "strings" + "testing" +) + +// captureLogger swaps slog.Default for a buffer-backed logger during the test +// and restores it when the test finishes. Returns the buffer so the test can +// inspect emitted log records. +func captureLogger(t *testing.T) *bytes.Buffer { + t.Helper() + buf := &bytes.Buffer{} + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { + slog.SetDefault(prev) + }) + return buf +} + +func TestIsHeaderBlocked(t *testing.T) { + tests := []struct { + name string + header string + userPathAlias string + want bool + }{ + {name: "authorization credential", header: "Authorization", want: true}, + {name: "lowercase authorization", header: "authorization", want: true}, + {name: "x-api-key credential", header: "X-Api-Key", want: true}, + {name: "cookie credential", header: "Cookie", want: true}, + {name: "set-cookie credential", header: "Set-Cookie", want: true}, + {name: "x-gomodel-key credential", header: "X-GoModel-Key", want: true}, + {name: "x-gomodel-user-path internal", header: "X-GoModel-User-Path", want: true}, + {name: "user path alias blocks header", header: "X-My-User-Path", userPathAlias: "X-My-User-Path", want: true}, + {name: "user path alias case-insensitive", header: "x-my-user-path", userPathAlias: "X-My-User-Path", want: true}, + {name: "user path alias whitespace trimmed", header: " X-My-User-Path ", userPathAlias: "X-My-User-Path", want: true}, + {name: "alias configured but header different", header: "X-Some-Other", userPathAlias: "X-My-User-Path", want: false}, + {name: "empty alias does not block extras", header: "X-Custom", userPathAlias: "", want: false}, + {name: "non-credential header allowed", header: "X-Custom-Header", want: false}, + {name: "content-type allowed", header: "Content-Type", want: false}, + {name: "accept allowed", header: "Accept", want: false}, + {name: "user-agent allowed", header: "User-Agent", want: false}, + {name: "empty header name", header: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsHeaderBlocked(tt.header, tt.userPathAlias) + if got != tt.want { + t.Errorf("IsHeaderBlocked(%q, %q) = %v, want %v", tt.header, tt.userPathAlias, got, tt.want) + } + }) + } +} + +func TestFilterIncomingHeaders_CopySemantics(t *testing.T) { + userPathHeader := http.CanonicalHeaderKey("X-GoModel-User-Path") + src := http.Header{ + "Authorization": {"Bearer secret"}, + "X-Api-Key": {"k"}, + userPathHeader: {"/v1/x"}, + "Content-Type": {"application/json"}, + "X-Custom": {"value"}, + } + + filtered := FilterIncomingHeaders(src, "") + + // Original must not be mutated. + if got := src.Get("Authorization"); got != "Bearer secret" { + t.Errorf("original Authorization mutated: %q", got) + } + if _, ok := src["X-Api-Key"]; !ok { + t.Error("original X-Api-Key removed unexpectedly") + } + + // Filtered must drop credentials and internal headers. + if got := filtered.Get("Authorization"); got != "" { + t.Errorf("filtered Authorization should be empty, got %q", got) + } + if _, ok := filtered["X-Api-Key"]; ok { + t.Error("filtered X-Api-Key should be dropped") + } + if _, ok := filtered[userPathHeader]; ok { + t.Error("filtered X-GoModel-User-Path should be dropped") + } + + // Filtered must keep non-credential headers. + if got := filtered.Get("Content-Type"); got != "application/json" { + t.Errorf("filtered Content-Type = %q, want application/json", got) + } + if got := filtered.Get("X-Custom"); got != "value" { + t.Errorf("filtered X-Custom = %q, want value", got) + } + + // Filtered must be a new map (not the same underlying reference). + if len(filtered) == 0 { + t.Error("expected non-empty filtered result") + } + filtered.Set("Content-Type", "mutated") + if src.Get("Content-Type") == "mutated" { + t.Error("mutation of filtered leaked back to source") + } +} + +func TestFilterIncomingHeaders_UserPathAlias(t *testing.T) { + src := http.Header{ + "X-My-Alias": {"v"}, + "X-Custom": {"keep"}, + } + filtered := FilterIncomingHeaders(src, "X-My-Alias") + if _, ok := filtered["X-My-Alias"]; ok { + t.Error("alias header should be filtered out") + } + if filtered.Get("X-Custom") != "keep" { + t.Error("non-alias header should be kept") + } +} + +func TestApplyHeaderOverrides_StaticMode(t *testing.T) { + tests := []struct { + name string + cfg HeaderOverridesConfig + userPathAlias string + wantHeaders map[string]string + wantMissing []string + }{ + { + name: "applies static custom headers", + cfg: HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Region": "us-east-1", + "X-Trace-Id": "abc-123", + }, + }, + wantHeaders: map[string]string{ + "X-Provider-Region": "us-east-1", + "X-Trace-Id": "abc-123", + }, + }, + { + name: "skips blocked credential header in static mode", + cfg: HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "Authorization": "Bearer leaked", + "X-Api-Key": "leaked", + "X-GoModel-User-Path": "/internal", + "X-Safe": "ok", + }, + }, + wantHeaders: map[string]string{ + "X-Safe": "ok", + }, + wantMissing: []string{"Authorization", "X-Api-Key", "X-GoModel-User-Path"}, + }, + { + name: "user path alias blocks configured header", + cfg: HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-My-Alias": "secret", + "X-Other": "ok", + }, + }, + userPathAlias: "X-My-Alias", + wantHeaders: map[string]string{ + "X-Other": "ok", + }, + wantMissing: []string{"X-My-Alias"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://example.com/v1/chat", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + ApplyHeaderOverrides(req, tt.cfg, tt.userPathAlias) + for k, want := range tt.wantHeaders { + if got := req.Header.Get(k); got != want { + t.Errorf("header %q = %q, want %q", k, got, want) + } + } + for _, k := range tt.wantMissing { + if got := req.Header.Get(k); got != "" { + t.Errorf("blocked header %q unexpectedly present: %q", k, got) + } + } + }) + } +} + +func TestApplyHeaderOverrides_NilAndZeroValue_NoOp(t *testing.T) { + t.Run("zero value config", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + req.Header.Set("X-Existing", "v") + ApplyHeaderOverrides(req, HeaderOverridesConfig{}, "") + if got := req.Header.Get("X-Existing"); got != "v" { + t.Errorf("zero-value config should be no-op, got %q", got) + } + if len(req.Header) != 1 { + t.Errorf("expected exactly 1 header, got %d (%v)", len(req.Header), req.Header) + } + }) + + t.Run("passthrough disabled no custom headers", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ApplyHeaderOverrides(req, HeaderOverridesConfig{PassthroughUserHeaders: false, CustomUpstreamHeaders: nil}, "") + if len(req.Header) != 0 { + t.Errorf("expected empty headers, got %v", req.Header) + } + }) + + t.Run("passthrough enabled but no context headers", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ApplyHeaderOverrides(req, HeaderOverridesConfig{PassthroughUserHeaders: true}, "") + if len(req.Header) != 0 { + t.Errorf("expected empty headers when no context, got %v", req.Header) + } + }) +} + +func TestApplyHeaderOverrides_PassthroughMode(t *testing.T) { + tests := []struct { + name string + ctxHeaders http.Header + cfg HeaderOverridesConfig + userPathAlias string + wantHeaders map[string]string + wantMissing []string + }{ + { + name: "skip mode (default) drops listed headers", + ctxHeaders: http.Header{ + "X-Tenant": {"acme"}, + "X-Skip-Me": {"nope"}, + "Content-Type": {"application/json"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Skip-Me"}, + SkipMode: "skip", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + "Content-Type": "application/json", + }, + wantMissing: []string{"X-Skip-Me"}, + }, + { + name: "skip mode empty mode string treated as skip", + ctxHeaders: http.Header{ + "X-Tenant": {"acme"}, + "X-Skip-Me": {"nope"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Skip-Me"}, + SkipMode: "", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + }, + wantMissing: []string{"X-Skip-Me"}, + }, + { + name: "allow mode forwards only listed headers", + ctxHeaders: http.Header{ + "X-Tenant": {"acme"}, + "X-Allowed": {"yes"}, + "X-Not-Listed": {"no"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Tenant", "X-Allowed"}, + SkipMode: "allow", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + "X-Allowed": "yes", + }, + wantMissing: []string{"X-Not-Listed"}, + }, + { + name: "only mode alias forwards only listed headers", + ctxHeaders: http.Header{ + "X-Tenant": {"acme"}, + "X-Allowed": {"yes"}, + "X-Not-Listed": {"no"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Tenant", "X-Allowed"}, + SkipMode: "only", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + "X-Allowed": "yes", + }, + wantMissing: []string{"X-Not-Listed"}, + }, + { + name: "only mode credential floor blocks even when allowlist includes them", + ctxHeaders: http.Header{ + "Authorization": {"Bearer user"}, + "X-Api-Key": {"k"}, + "X-Tenant": {"acme"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"Authorization", "X-Api-Key", "X-Tenant"}, + SkipMode: "only", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + }, + wantMissing: []string{"Authorization", "X-Api-Key"}, + }, + { + name: "credential floor blocks even when allowed list includes them", + ctxHeaders: http.Header{ + "Authorization": {"Bearer user"}, + "X-Api-Key": {"k"}, + "X-Tenant": {"acme"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"Authorization", "X-Api-Key", "X-Tenant"}, + SkipMode: "allow", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + }, + wantMissing: []string{"Authorization", "X-Api-Key"}, + }, + { + name: "internal header blocked in passthrough", + ctxHeaders: http.Header{ + "X-GoModel-User-Path": {"/v1/internal"}, + "X-Tenant": {"acme"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipMode: "skip", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + }, + wantMissing: []string{"X-GoModel-User-Path"}, + }, + { + name: "user path alias blocks in passthrough", + ctxHeaders: http.Header{ + "X-My-Alias": {"value"}, + "X-Tenant": {"acme"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipMode: "skip", + }, + userPathAlias: "X-My-Alias", + wantHeaders: map[string]string{ + "X-Tenant": "acme", + }, + wantMissing: []string{"X-My-Alias"}, + }, + { + name: "skip mode case-insensitive", + ctxHeaders: http.Header{ + "X-Tenant": {"acme"}, + "X-Skip-Me": {"nope"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"x-skip-me"}, + SkipMode: "skip", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + }, + wantMissing: []string{"X-Skip-Me"}, + }, + { + name: "skip mode with whitespace in skip list", + ctxHeaders: http.Header{ + "X-Tenant": {"acme"}, + "X-Skip-Me": {"nope"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{" X-Skip-Me "}, + SkipMode: "skip", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + }, + wantMissing: []string{"X-Skip-Me"}, + }, + { + name: "unknown mode defaults to skip", + ctxHeaders: http.Header{ + "X-Tenant": {"acme"}, + "X-Skip-Me": {"nope"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Skip-Me"}, + SkipMode: "bogus", + }, + wantHeaders: map[string]string{ + "X-Tenant": "acme", + }, + wantMissing: []string{"X-Skip-Me"}, + }, + { + name: "multi-value header preserved (first retrieved by Get)", + ctxHeaders: http.Header{ + "X-Tenant": {"a", "b", "c"}, + }, + cfg: HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipMode: "skip", + }, + wantHeaders: map[string]string{ + "X-Tenant": "a", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ctx := WithPassthroughHeaders(req.Context(), tt.ctxHeaders) + req = req.WithContext(ctx) + + ApplyHeaderOverrides(req, tt.cfg, tt.userPathAlias) + + for k, want := range tt.wantHeaders { + if got := req.Header.Get(k); got != want { + t.Errorf("header %q = %q, want %q", k, got, want) + } + } + for _, k := range tt.wantMissing { + if got := req.Header.Get(k); got != "" { + t.Errorf("blocked header %q unexpectedly present: %q", k, got) + } + } + }) + } +} + +func TestApplyHeaderOverrides_PassthroughWithCustomHeadersEmitsDebug(t *testing.T) { + buf := captureLogger(t) + + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ctx := WithPassthroughHeaders(req.Context(), http.Header{"X-Tenant": {"acme"}}) + req = req.WithContext(ctx) + + cfg := HeaderOverridesConfig{ + PassthroughUserHeaders: true, + CustomUpstreamHeaders: map[string]string{ + "X-Ignored": "value", + }, + } + ApplyHeaderOverrides(req, cfg, "") + + if !strings.Contains(buf.String(), "custom_upstream_headers ignored") { + t.Errorf("expected debug log about ignored custom headers, got: %q", buf.String()) + } + if req.Header.Get("X-Tenant") != "acme" { + t.Errorf("passthrough header X-Tenant not applied: %q", req.Header.Get("X-Tenant")) + } + if req.Header.Get("X-Ignored") != "" { + t.Errorf("custom header should be ignored when passthrough active, got %q", req.Header.Get("X-Ignored")) + } +} + +func TestApplyHeaderOverrides_PassthroughAlwaysWarnsWithCustomHeaders(t *testing.T) { + // When both passthrough and custom headers are configured, the warn fires + // regardless of whether the request context carries user headers — the + // warning is about config-shape, not per-request data. + buf := captureLogger(t) + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + cfg := HeaderOverridesConfig{ + PassthroughUserHeaders: true, + CustomUpstreamHeaders: map[string]string{ + "X-Ignored": "value", + }, + } + ApplyHeaderOverrides(req, cfg, "") + if !strings.Contains(buf.String(), "custom_upstream_headers ignored") { + t.Errorf("expected debug warning when both passthrough and custom headers set, got: %q", buf.String()) + } +} + +func TestPassthroughContextRoundTrip(t *testing.T) { + t.Run("round-trip preserves headers", func(t *testing.T) { + want := http.Header{ + "X-Tenant": {"acme"}, + "Content-Type": {"application/json"}, + } + ctx := WithPassthroughHeaders(context.Background(), want) + got := PassthroughHeadersFromContext(ctx) + if got == nil { + t.Fatal("expected headers from context, got nil") + } + if got.Get("X-Tenant") != "acme" { + t.Errorf("X-Tenant = %q, want acme", got.Get("X-Tenant")) + } + if got.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got.Get("Content-Type")) + } + }) + + t.Run("missing context key returns nil", func(t *testing.T) { + got := PassthroughHeadersFromContext(context.Background()) + if got != nil { + t.Errorf("expected nil for empty context, got %v", got) + } + }) + + t.Run("wrong type in context returns nil", func(t *testing.T) { + ctx := context.WithValue(context.Background(), passthroughCtxKey{}, "not a header") + got := PassthroughHeadersFromContext(ctx) + if got != nil { + t.Errorf("expected nil for wrong type, got %v", got) + } + }) + + t.Run("storing nil headers returns nil on retrieval", func(t *testing.T) { + ctx := WithPassthroughHeaders(context.Background(), nil) + got := PassthroughHeadersFromContext(ctx) + if got != nil { + t.Errorf("expected nil for stored nil, got %v", got) + } + }) +} + +func TestShouldForward(t *testing.T) { + skipSet := normalizeHeaderSet([]string{"X-Skip-Me", "X-Other"}) + + tests := []struct { + name string + header string + mode string + userPathAlias string + want bool + }{ + {name: "skip mode: not in skip list", header: "X-Tenant", mode: "skip", want: true}, + {name: "skip mode: in skip list", header: "X-Skip-Me", mode: "skip", want: false}, + {name: "skip mode: case-insensitive", header: "x-skip-me", mode: "skip", want: false}, + {name: "skip mode: empty mode defaults to skip", header: "X-Skip-Me", mode: "", want: false}, + {name: "allow mode: in allow list", header: "X-Other", mode: "allow", want: true}, + {name: "allow mode: not in allow list", header: "X-Tenant", mode: "allow", want: false}, + {name: "allow mode: case-insensitive", header: "x-skip-me", mode: "allow", want: true}, + {name: "unknown mode: defaults to skip behavior", header: "X-Skip-Me", mode: "bogus", want: false}, + {name: "unknown mode: not in skip list passes", header: "X-Tenant", mode: "bogus", want: true}, + {name: "credential blocked regardless of mode", header: "Authorization", mode: "skip", want: false}, + {name: "credential blocked in allow mode", header: "Authorization", mode: "allow", want: false}, + {name: "internal header blocked", header: "X-GoModel-User-Path", mode: "skip", want: false}, + {name: "user path alias blocked", header: "X-My-Alias", mode: "skip", userPathAlias: "X-My-Alias", want: false}, + {name: "user path alias blocked in allow mode", header: "X-My-Alias", mode: "allow", userPathAlias: "X-My-Alias", want: false}, + {name: "user path alias not set does not block extras", header: "X-My-Alias", mode: "skip", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldForward(tt.header, skipSet, tt.mode, tt.userPathAlias) + if got != tt.want { + t.Errorf("shouldForward(%q, mode=%q, alias=%q) = %v, want %v", tt.header, tt.mode, tt.userPathAlias, got, tt.want) + } + }) + } +} + +func TestApplyHeaderOverrides_PassthroughAppendsToExisting(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + req.Header.Set("X-Existing", "preset") + ctx := WithPassthroughHeaders(req.Context(), http.Header{ + "X-Tenant": {"acme"}, + }) + req = req.WithContext(ctx) + + cfg := HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipMode: "skip", + } + ApplyHeaderOverrides(req, cfg, "") + + if got := req.Header.Get("X-Existing"); got != "preset" { + t.Errorf("existing header overwritten: %q", got) + } + if got := req.Header.Get("X-Tenant"); got != "acme" { + t.Errorf("passthrough header not applied: %q", got) + } +} + +// TestApplyHeaderOverrides_PassthroughMode_ForwardsFilteredHeaders verifies +// the happy-path passthrough contract: with PassthroughUserHeaders enabled and +// no skip/allow list, every non-blocked context header is forwarded to the +// outbound request, and the request header map is enriched without mutating +// the source context. +func TestApplyHeaderOverrides_PassthroughMode_ForwardsFilteredHeaders(t *testing.T) { + src := http.Header{ + "X-Tenant": {"acme"}, + "X-Custom": {"hello"}, + "Content-Type": {"application/json"}, + "Accept": {"application/json"}, + } + + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ctx := WithPassthroughHeaders(req.Context(), src) + req = req.WithContext(ctx) + + cfg := HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipMode: "skip", + } + ApplyHeaderOverrides(req, cfg, "") + + for k, want := range map[string]string{ + "X-Tenant": "acme", + "X-Custom": "hello", + "Content-Type": "application/json", + "Accept": "application/json", + } { + if got := req.Header.Get(k); got != want { + t.Errorf("header %q = %q, want %q", k, got, want) + } + } + + // Source context must remain untouched. + if got := src.Get("X-Tenant"); got != "acme" { + t.Errorf("source context X-Tenant mutated: %q", got) + } +} + +// TestApplyHeaderOverrides_PassthroughMode_SkipList verifies that the skip +// mode (default) forwards all headers except those explicitly listed in +// SkipHeaders. Listing is case-insensitive and trims whitespace. +func TestApplyHeaderOverrides_PassthroughMode_SkipList(t *testing.T) { + src := http.Header{ + "X-Tenant": {"acme"}, + "X-Skip-Me": {"nope"}, + "Content-Type": {"application/json"}, + } + + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ctx := WithPassthroughHeaders(req.Context(), src) + req = req.WithContext(ctx) + + cfg := HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{" x-skip-me "}, + SkipMode: "skip", + } + ApplyHeaderOverrides(req, cfg, "") + + if got := req.Header.Get("X-Tenant"); got != "acme" { + t.Errorf("X-Tenant = %q, want %q", got, "acme") + } + if got := req.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want %q", got, "application/json") + } + if got := req.Header.Get("X-Skip-Me"); got != "" { + t.Errorf("X-Skip-Me should be skipped, got %q", got) + } +} + +// TestApplyHeaderOverrides_PassthroughMode_AllowMode verifies that allow mode +// forwards only headers present in SkipHeaders (interpreted as the allow +// list). Any other context header must not reach the outbound request. +func TestApplyHeaderOverrides_PassthroughMode_AllowMode(t *testing.T) { + src := http.Header{ + "X-Tenant": {"acme"}, + "X-Allowed": {"yes"}, + "X-Not-Listed": {"no"}, + "X-Other": {"x"}, + } + + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ctx := WithPassthroughHeaders(req.Context(), src) + req = req.WithContext(ctx) + + cfg := HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Tenant", "X-Allowed"}, + SkipMode: "allow", + } + ApplyHeaderOverrides(req, cfg, "") + + if got := req.Header.Get("X-Tenant"); got != "acme" { + t.Errorf("X-Tenant = %q, want %q", got, "acme") + } + if got := req.Header.Get("X-Allowed"); got != "yes" { + t.Errorf("X-Allowed = %q, want %q", got, "yes") + } + for _, missing := range []string{"X-Not-Listed", "X-Other"} { + if got := req.Header.Get(missing); got != "" { + t.Errorf("%s must be blocked in allow mode, got %q", missing, got) + } + } +} + +// TestApplyHeaderOverrides_PassthroughMode_BlockedHeadersNeverForwarded +// guarantees the hard-coded floor can never be bypassed. Even when a credential +// header is explicitly listed in the allow list, allow mode must drop it +// because the floor check runs before skip/allow evaluation. +func TestApplyHeaderOverrides_PassthroughMode_BlockedHeadersNeverForwarded(t *testing.T) { + src := http.Header{ + "Authorization": {"Bearer user"}, + "X-Api-Key": {"k"}, + "X-GoModel-Key": {"mgmt"}, + "Cookie": {"session=secret"}, + "X-GoModel-User-Path": {"/v1/x"}, + "X-Tenant": {"acme"}, + } + + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ctx := WithPassthroughHeaders(req.Context(), src) + req = req.WithContext(ctx) + + cfg := HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{ + "Authorization", + "X-Api-Key", + "X-GoModel-Key", + "Cookie", + "X-GoModel-User-Path", + "X-Tenant", + }, + SkipMode: "allow", + } + ApplyHeaderOverrides(req, cfg, "") + + // X-Tenant is the only non-blocked header and must come through. + if got := req.Header.Get("X-Tenant"); got != "acme" { + t.Errorf("X-Tenant = %q, want %q", got, "acme") + } + // Every blocked header must remain absent regardless of the allow list. + for _, blocked := range []string{"Authorization", "X-Api-Key", "X-GoModel-Key", "Cookie", "X-GoModel-User-Path"} { + if got := req.Header.Get(blocked); got != "" { + t.Errorf("blocked header %q leaked through passthrough allow list: %q", blocked, got) + } + } +} + +// capturingSlogHandler is a minimal slog.Handler used by the passthrough +// logging test to assert on emitted log records without coupling to the +// global slog default. +type capturingSlogHandler struct { + records []slogRecord +} + +type slogRecord struct { + level slog.Level + msg string + attrs map[string]any +} + +func (h *capturingSlogHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } +func (h *capturingSlogHandler) Handle(_ context.Context, r slog.Record) error { + rec := slogRecord{level: r.Level, msg: r.Message, attrs: make(map[string]any)} + r.Attrs(func(a slog.Attr) bool { + rec.attrs[a.Key] = a.Value.Any() + return true + }) + h.records = append(h.records, rec) + return nil +} +func (h *capturingSlogHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *capturingSlogHandler) WithGroup(_ string) slog.Handler { return h } + +// TestApplyHeaderOverrides_PassthroughMode_LogsIgnoredCustomHeaders verifies +// the operator-facing debug log emitted when both PassthroughUserHeaders and +// CustomUpstreamHeaders are configured. The test installs a dedicated +// capturing slog.Handler on a transient logger and asserts the message lands +// at debug level with the expected content. +func TestApplyHeaderOverrides_PassthroughMode_LogsIgnoredCustomHeaders(t *testing.T) { + handler := &capturingSlogHandler{} + logger := slog.New(handler) + prev := slog.Default() + slog.SetDefault(logger) + t.Cleanup(func() { slog.SetDefault(prev) }) + + req, _ := http.NewRequest(http.MethodPost, "http://example.com", nil) + ctx := WithPassthroughHeaders(req.Context(), http.Header{"X-Tenant": {"acme"}}) + req = req.WithContext(ctx) + + cfg := HeaderOverridesConfig{ + PassthroughUserHeaders: true, + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Region": "us-east-1", + "X-Trace-Id": "abc-123", + }, + } + ApplyHeaderOverrides(req, cfg, "") + + // Find the ignorelog record. + var found *slogRecord + for i := range handler.records { + if handler.records[i].level == slog.LevelDebug && + strings.Contains(handler.records[i].msg, "custom_upstream_headers") { + found = &handler.records[i] + break + } + } + if found == nil { + t.Fatalf("expected debug log about ignored custom headers, got records: %+v", handler.records) + } + if !strings.Contains(found.msg, "ignored") { + t.Errorf("expected 'ignored' in message, got %q", found.msg) + } + if !strings.Contains(found.msg, "passthrough_user_headers") { + t.Errorf("expected message to reference passthrough_user_headers, got %q", found.msg) + } + + // Sanity: passthrough still applied, custom headers did not leak. + if got := req.Header.Get("X-Tenant"); got != "acme" { + t.Errorf("passthrough header X-Tenant not applied: %q", got) + } + if got := req.Header.Get("X-Provider-Region"); got != "" { + t.Errorf("X-Provider-Region should be ignored when passthrough active, got %q", got) + } +} diff --git a/internal/providers/ollama/ollama_test.go b/internal/providers/ollama/ollama_test.go index 9a7da3dc..7b3a1fb4 100644 --- a/internal/providers/ollama/ollama_test.go +++ b/internal/providers/ollama/ollama_test.go @@ -18,7 +18,7 @@ import ( func TestNew(t *testing.T) { apiKey := "test-api-key" // Use NewWithHTTPClient to get concrete type for internal testing - provider := NewWithHTTPClient(apiKey, nil, llmclient.Hooks{}) + provider := NewWithHTTPClient(apiKey, nil, llmclient.Hooks{}, nil, "") if provider.apiKey != apiKey { t.Errorf("apiKey = %q, want %q", provider.apiKey, apiKey) @@ -41,7 +41,7 @@ func TestNew_ReturnsProvider(t *testing.T) { func TestNew_WithoutAPIKey(t *testing.T) { // Ollama doesn't require an API key - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") if provider.apiKey != "" { t.Errorf("apiKey = %q, want empty", provider.apiKey) @@ -137,7 +137,7 @@ func TestChatCompletion(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ChatRequest{ @@ -188,7 +188,7 @@ func TestChatCompletion_WithAPIKey(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ChatRequest{ @@ -222,7 +222,7 @@ func TestChatCompletion_WithoutAPIKey(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ChatRequest{ @@ -288,7 +288,7 @@ data: [DONE] })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ChatRequest{ @@ -394,7 +394,7 @@ func TestListModels(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) resp, err := provider.ListModels(context.Background()) @@ -423,7 +423,7 @@ func TestChatCompletionWithContext(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) ctx, cancel := context.WithCancel(context.Background()) @@ -472,7 +472,7 @@ func TestResponses(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ResponsesRequest{ @@ -563,7 +563,7 @@ func TestResponsesWithArrayInput(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ResponsesRequest{ @@ -616,7 +616,7 @@ data: [DONE] })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ResponsesRequest{ @@ -658,7 +658,7 @@ func TestResponsesWithContext(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) ctx, cancel := context.WithCancel(context.Background()) @@ -720,7 +720,7 @@ func TestNewWithHTTPClient(t *testing.T) { customClient := &http.Client{} apiKey := "test-api-key" - provider := NewWithHTTPClient(apiKey, customClient, llmclient.Hooks{}) + provider := NewWithHTTPClient(apiKey, customClient, llmclient.Hooks{}, nil, "") if provider.apiKey != apiKey { t.Errorf("apiKey = %q, want %q", provider.apiKey, apiKey) @@ -734,7 +734,7 @@ func TestNewWithHTTPClient(t *testing.T) { } func TestSetBaseURL(t *testing.T) { - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") customURL := "http://custom.ollama.server:11434/v1" provider.SetBaseURL(customURL) @@ -761,7 +761,7 @@ func TestSetBaseURL_TrailingSlash(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL + "/v1/") _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ @@ -807,7 +807,7 @@ func TestEmbeddings(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL + "/v1") resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ @@ -859,7 +859,7 @@ func TestEmbeddings_ModelFallback(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL + "/v1") resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ @@ -887,7 +887,7 @@ func TestEmbeddings_NoVectorsErrors(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL + "/v1") resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ @@ -924,7 +924,7 @@ func TestEmbeddings_EmptyInputNoError(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL + "/v1") for _, empty := range []any{[]any{}, []string{}} { @@ -952,3 +952,299 @@ func TestEmbeddings_EmptyInputNoError(t *testing.T) { } } } + +// TestChatCompletion_StaticHeaders verifies that custom upstream headers from +// HeaderOverridesConfig are applied to the OpenAI-compatible client. +func TestChatCompletion_StaticHeaders(t *testing.T) { + var gotRegion, gotTrace string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRegion = r.Header.Get("X-Provider-Region") + gotTrace = r.Header.Get("X-Trace-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-ollama", + "created":1677652288, + "model":"llama3", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Region": "us-east-1", + "X-Trace-Id": "trace-abc", + }, + } + provider := NewWithHTTPClient("ollama-key", server.Client(), llmclient.Hooks{}, cfg, "") + provider.SetBaseURL(server.URL) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "llama3", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotRegion != "us-east-1" { + t.Errorf("X-Provider-Region = %q, want us-east-1", gotRegion) + } + if gotTrace != "trace-abc" { + t.Errorf("X-Trace-Id = %q, want trace-abc", gotTrace) + } +} + +// TestEmbeddings_StaticHeaders verifies that custom upstream headers are +// applied to the native /api/embed client (separate llmclient instance). +func TestEmbeddings_StaticHeaders(t *testing.T) { + var gotRegion, gotTrace string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/embed" { + t.Errorf("Path = %q, want %q", r.URL.Path, "/api/embed") + } + gotRegion = r.Header.Get("X-Provider-Region") + gotTrace = r.Header.Get("X-Trace-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "model":"nomic-embed-text", + "embeddings":[[0.1,0.2,0.3]], + "prompt_eval_count":3 + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Region": "eu-west-1", + "X-Trace-Id": "trace-embed", + }, + } + provider := NewWithHTTPClient("ollama-key", server.Client(), llmclient.Hooks{}, cfg, "") + provider.SetBaseURL(server.URL + "/v1") + + resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ + Model: "nomic-embed-text", + Input: "hello", + }) + if err != nil { + t.Fatalf("Embeddings() error = %v", err) + } + if len(resp.Data) != 1 { + t.Fatalf("len(resp.Data) = %d, want 1", len(resp.Data)) + } + if gotRegion != "eu-west-1" { + t.Errorf("X-Provider-Region = %q, want eu-west-1", gotRegion) + } + if gotTrace != "trace-embed" { + t.Errorf("X-Trace-Id = %q, want trace-embed", gotTrace) + } +} + +// TestChatCompletion_StaticHeaders_RespectsUserPathAlias verifies that an alias +// configured via userPathAlias is blocked from forwarding on the OpenAI-compatible client. +func TestChatCompletion_StaticHeaders_RespectsUserPathAlias(t *testing.T) { + var gotAlias, gotOther string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAlias = r.Header.Get("X-My-Alias") + gotOther = r.Header.Get("X-Other") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-ollama", + "created":1677652288, + "model":"llama3", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-My-Alias": "secret", + "X-Other": "ok", + }, + } + provider := NewWithHTTPClient("ollama-key", server.Client(), llmclient.Hooks{}, cfg, "X-My-Alias") + provider.SetBaseURL(server.URL) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "llama3", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotAlias != "" { + t.Errorf("X-My-Alias = %q, want empty (blocked by alias)", gotAlias) + } + if gotOther != "ok" { + t.Errorf("X-Other = %q, want ok", gotOther) + } +} + +// TestChatCompletion_PassthroughUserHeaders verifies that passthrough mode +// applies user-supplied headers to the OpenAI-compatible client while still +// blocking credentials and the X-GoModel-User-Path hard-coded floor. +func TestChatCompletion_PassthroughUserHeaders(t *testing.T) { + var gotCustom, gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCustom = r.Header.Get("X-User-Custom") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-ollama", + "created":1677652288, + "model":"llama3", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + PassthroughUserHeaders: true, + } + // Empty apiKey: SetAuthHeaders with OptionalAPIKey=true will not set Authorization, + // so the user-supplied Authorization in the context cannot leak. + provider := NewWithHTTPClient("", server.Client(), llmclient.Hooks{}, cfg, "X-GoModel-User-Path") + provider.SetBaseURL(server.URL) + + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-User-Custom": {"user-value"}, + "X-Other-Pass": {"other-value"}, + "Authorization": {"Bearer leaked"}, + "X-GoModel-User-Path": {"/internal"}, + }) + + if _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "llama3", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotCustom != "user-value" { + t.Errorf("X-User-Custom = %q, want user-value", gotCustom) + } + if gotAuth != "" { + t.Errorf("Authorization = %q, want empty (blocked hard-coded credential)", gotAuth) + } +} + +// TestEmbeddings_PassthroughUserHeaders verifies that passthrough mode applies +// user-supplied headers to the native /api/embed client as well. +func TestEmbeddings_PassthroughUserHeaders(t *testing.T) { + var gotCustom, gotTrace string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/embed" { + t.Errorf("Path = %q, want %q", r.URL.Path, "/api/embed") + } + gotCustom = r.Header.Get("X-User-Custom") + gotTrace = r.Header.Get("X-Trace-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "model":"nomic-embed-text", + "embeddings":[[0.1,0.2,0.3]], + "prompt_eval_count":3 + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + PassthroughUserHeaders: true, + } + provider := NewWithHTTPClient("ollama-key", server.Client(), llmclient.Hooks{}, cfg, "X-GoModel-User-Path") + provider.SetBaseURL(server.URL + "/v1") + + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-User-Custom": {"user-value"}, + "X-Trace-Id": {"trace-pass"}, + "X-GoModel-User-Path": {"/internal"}, + }) + + if _, err := provider.Embeddings(ctx, &core.EmbeddingRequest{ + Model: "nomic-embed-text", + Input: "hello", + }); err != nil { + t.Fatalf("Embeddings() error = %v", err) + } + if gotCustom != "user-value" { + t.Errorf("X-User-Custom = %q, want user-value", gotCustom) + } + if gotTrace != "trace-pass" { + t.Errorf("X-Trace-Id = %q, want trace-pass", gotTrace) + } +} + +// TestNew_FactoryWiringPassesHeaderOverridesAndUserPathAlias verifies the +// factory constructor wires HeaderOverrides and UserPathHeader from +// ProviderOptions into both clients. +func TestNew_FactoryWiringPassesHeaderOverridesAndUserPathAlias(t *testing.T) { + var chatGotCustom, chatGotAuth string + var embedGotCustom string + + mux := http.NewServeMux() + // The OpenAI-compatible client uses base URL server.URL + "/v1". + mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + chatGotCustom = r.Header.Get("X-Provider-Custom") + chatGotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-ollama", + "created":1677652288, + "model":"llama3", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + }) + // The native client derives base URL by stripping the "/v1" suffix. + mux.HandleFunc("/api/embed", func(w http.ResponseWriter, r *http.Request) { + embedGotCustom = r.Header.Get("X-Provider-Custom") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "model":"nomic-embed-text", + "embeddings":[[0.1]], + "prompt_eval_count":1 + }`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + cfg := providers.ProviderConfig{ + Type: "ollama", + APIKey: "ollama-key", + BaseURL: server.URL + "/v1", + } + opts := providers.ProviderOptions{ + UserPathHeader: "X-GoModel-User-Path", + HeaderOverrides: &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Custom": "factory-value", + }, + }, + } + + provider := New(cfg, opts) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "llama3", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if chatGotCustom != "factory-value" { + t.Errorf("chat X-Provider-Custom = %q, want factory-value", chatGotCustom) + } + if chatGotAuth != "Bearer ollama-key" { + t.Errorf("chat Authorization = %q, want Bearer ollama-key", chatGotAuth) + } + + if _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ + Model: "nomic-embed-text", + Input: "hello", + }); err != nil { + t.Fatalf("Embeddings() error = %v", err) + } + if embedGotCustom != "factory-value" { + t.Errorf("embed X-Provider-Custom = %q, want factory-value", embedGotCustom) + } +} diff --git a/internal/providers/openai/compatible_provider_test.go b/internal/providers/openai/compatible_provider_test.go index c4793416..fadc35fe 100644 --- a/internal/providers/openai/compatible_provider_test.go +++ b/internal/providers/openai/compatible_provider_test.go @@ -4,10 +4,12 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" "gomodel/internal/core" "gomodel/internal/llmclient" + "gomodel/internal/providers" ) func TestCompatibleProvider_ListModels_ReturnsUpstreamOnSuccess(t *testing.T) { @@ -94,3 +96,172 @@ func TestCompatibleProvider_ListModels_ReturnsUpstreamError(t *testing.T) { t.Errorf("gatewayErr.Type = %q, want provider_error or not_found_error", gatewayErr.Type) } } + +func TestCompatibleProvider_AppliesHeaderOverrides(t *testing.T) { + var capturedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) + })) + defer server.Close() + + customHeaders := map[string]string{ + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + } + + opts := providers.ProviderOptions{ + HeaderOverrides: &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: customHeaders, + SkipHeaders: []string{"X-Skipped-Header"}, + }, + UserPathHeader: "X-User-Path", + } + + provider := NewCompatibleProvider( + "test-key", + opts, + CompatibleProviderConfig{ + ProviderName: "test-provider", + BaseURL: server.URL, + }, + ) + + _, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + + // Verify custom headers are applied + if capturedHeaders.Get("X-Custom-Header") != "custom-value" { + t.Errorf("X-Custom-Header = %q, want custom-value", capturedHeaders.Get("X-Custom-Header")) + } + if capturedHeaders.Get("X-Another-Header") != "another-value" { + t.Errorf("X-Another-Header = %q, want another-value", capturedHeaders.Get("X-Another-Header")) + } + + // Verify internal headers are blocked + if capturedHeaders.Get("X-GoModel-User-Path") != "" { + t.Errorf("X-GoModel-User-Path should be blocked, got %q", capturedHeaders.Get("X-GoModel-User-Path")) + } + if capturedHeaders.Get("Authorization") != "" { + t.Errorf("Authorization header should be blocked, got %q", capturedHeaders.Get("Authorization")) + } +} + +// TestCompatibleProvider_HeaderOverridesViaWithHTTPClient exercises the +// NewCompatibleProviderWithHTTPClient path: the custom HTTP client is +// honored while the HeaderOverrides config still drives custom headers +// and the hard-coded block list still strips credential headers. +func TestCompatibleProvider_HeaderOverridesViaWithHTTPClient(t *testing.T) { + var capturedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"m","object":"model","owned_by":"x"}]}`)) + })) + defer server.Close() + + cfg := CompatibleProviderConfig{ + ProviderName: "with-http-client", + BaseURL: server.URL, + HeaderOverrides: &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + "Authorization": "should-be-blocked", // hard-coded credential block + "X-GoModel-User-Path": "should-be-blocked", // hard-coded internal block + }, + }, + } + + provider := NewCompatibleProviderWithHTTPClient( + "test-key", + server.Client(), + llmclient.Hooks{}, + cfg, + ) + + if _, err := provider.ListModels(context.Background()); err != nil { + t.Fatalf("ListModels() error = %v", err) + } + + if got := capturedHeaders.Get("X-Custom-Header"); got != "custom-value" { + t.Errorf("X-Custom-Header = %q, want custom-value", got) + } + if got := capturedHeaders.Get("X-Another-Header"); got != "another-value" { + t.Errorf("X-Another-Header = %q, want another-value", got) + } + if got := capturedHeaders.Get("X-GoModel-User-Path"); got != "" { + t.Errorf("X-GoModel-User-Path = %q, want empty (blocked)", got) + } + // Authorization set via custom header map must be skipped by the static + // block list; the only Authorization on the wire is the one set by + // SetHeaders (Bearer ...), and the custom value must not be appended. + for _, v := range capturedHeaders.Values("Authorization") { + if strings.HasPrefix(v, "should-be-blocked") { + t.Errorf("Authorization leaked custom value %q", v) + } + } +} + +// TestCompatibleProvider_HeaderOverridesViaChatCompatible proves the +// ChatCompatible → CompatibleProvider delegation path receives +// HeaderOverrides. This is the same path Kimi and other ChatCompatible +// providers use, so a passing test here implies those providers will +// forward custom headers as well. +func TestCompatibleProvider_HeaderOverridesViaChatCompatible(t *testing.T) { + var capturedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"m","object":"model","owned_by":"x"}]}`)) + })) + defer server.Close() + + opts := providers.ProviderOptions{ + HeaderOverrides: &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + "Authorization": "should-be-blocked", // hard-coded credential block + "X-GoModel-User-Path": "should-be-blocked", // hard-coded internal block + }, + }, + UserPathHeader: "X-User-Path", + } + + chat := NewChatCompatible( + "test-key", + opts, + CompatibleProviderConfig{ + ProviderName: "chat-compatible", + BaseURL: server.URL, + }, + ) + + if _, err := chat.ListModels(context.Background()); err != nil { + t.Fatalf("ListModels() error = %v", err) + } + + if got := capturedHeaders.Get("X-Custom-Header"); got != "custom-value" { + t.Errorf("X-Custom-Header = %q, want custom-value", got) + } + if got := capturedHeaders.Get("X-Another-Header"); got != "another-value" { + t.Errorf("X-Another-Header = %q, want another-value", got) + } + // Hard-coded blocks must be skipped. + if got := capturedHeaders.Get("X-GoModel-User-Path"); got != "" { + t.Errorf("X-GoModel-User-Path = %q, want empty (blocked)", got) + } + for _, v := range capturedHeaders.Values("Authorization") { + if strings.HasPrefix(v, "should-be-blocked") { + t.Errorf("Authorization leaked custom value %q", v) + } + } + // Bearer auth must still be present (set by the default SetHeaders). + if got := capturedHeaders.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") { + t.Errorf("Authorization = %q, want Bearer prefix", got) + } +} diff --git a/internal/providers/vllm/vllm_test.go b/internal/providers/vllm/vllm_test.go index eac2fa51..465916cb 100644 --- a/internal/providers/vllm/vllm_test.go +++ b/internal/providers/vllm/vllm_test.go @@ -10,6 +10,7 @@ import ( "gomodel/internal/core" "gomodel/internal/llmclient" + "gomodel/internal/providers" ) func TestChatCompletion_UsesOptionalBearerAuthAndChatEndpoint(t *testing.T) { @@ -40,7 +41,7 @@ func TestChatCompletion_UsesOptionalBearerAuthAndChatEndpoint(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient(tt.apiKey, server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient(tt.apiKey, server.URL, server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ Model: "meta-llama/Llama-3.1-8B-Instruct", @@ -79,7 +80,7 @@ func TestEmbeddings_DelegatesToCompatibleProvider(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("", server.URL, server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ Model: "BAAI/bge-small-en-v1.5", @@ -97,7 +98,7 @@ func TestEmbeddings_DelegatesToCompatibleProvider(t *testing.T) { } func TestProvider_ExposesPassthroughButNotOptionalNativeInterfaces(t *testing.T) { - provider := NewWithHTTPClient("", "", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("", "", nil, llmclient.Hooks{}, nil, "") if _, ok := any(provider).(core.PassthroughProvider); !ok { t.Fatal("vllm provider should implement passthrough provider") @@ -125,7 +126,7 @@ func TestPassthrough_ForwardsProviderNativeEndpoint(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("vllm-key", server.URL, server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("vllm-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ Method: http.MethodPost, @@ -156,7 +157,7 @@ func TestPassthrough_UsesRootForNativeEndpointsWhenBaseURLIncludesV1(t *testing. })) defer server.Close() - provider := NewWithHTTPClient("", server.URL+"/v1", server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("", server.URL+"/v1", server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ Method: http.MethodPost, @@ -189,7 +190,7 @@ func TestPassthrough_UsesV1ForOpenAICompatibleEndpointsWhenBaseURLIncludesV1(t * })) defer server.Close() - provider := NewWithHTTPClient("", server.URL+"/v1", server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("", server.URL+"/v1", server.Client(), llmclient.Hooks{}, nil, "") resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ Method: http.MethodPost, @@ -209,3 +210,309 @@ func TestPassthrough_UsesV1ForOpenAICompatibleEndpointsWhenBaseURLIncludesV1(t * t.Fatalf("path = %q, want /v1/chat/completions", gotPath) } } + +// TestPassthrough_Root_AppliesStaticCustomHeaders verifies that custom upstream +// headers from HeaderOverridesConfig are applied to the non-v1 root passthrough +// client (separate from the OpenAI-compatible client). +func TestPassthrough_Root_AppliesStaticCustomHeaders(t *testing.T) { + var gotRegion, gotTrace, gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRegion = r.Header.Get("X-Provider-Region") + gotTrace = r.Header.Get("X-Trace-Id") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tokens":[1,2,3]}`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Region": "us-east-1", + "X-Trace-Id": "trace-abc", + }, + } + provider := NewWithHTTPClient("vllm-key", server.URL+"/v1", server.Client(), llmclient.Hooks{}, cfg, "") + + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: "tokenize", + Body: io.NopCloser(strings.NewReader("{}")), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + if gotRegion != "us-east-1" { + t.Errorf("X-Provider-Region = %q, want us-east-1", gotRegion) + } + if gotTrace != "trace-abc" { + t.Errorf("X-Trace-Id = %q, want trace-abc", gotTrace) + } + if gotAuth != "Bearer vllm-key" { + t.Errorf("Authorization = %q, want Bearer vllm-key", gotAuth) + } +} + +// TestPassthrough_Root_StaticHeaders_BlocksCredentialAndInternal verifies that +// static custom headers on the non-v1 root passthrough client cannot leak +// credentials or the X-GoModel-User-Path internal alias. +func TestPassthrough_Root_StaticHeaders_BlocksCredentialAndInternal(t *testing.T) { + var gotAuth, gotAPIKey, gotInternal, gotSafe string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotAPIKey = r.Header.Get("X-Api-Key") + gotInternal = r.Header.Get("X-GoModel-User-Path") + gotSafe = r.Header.Get("X-Safe") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "Authorization": "Bearer leaked", + "X-Api-Key": "leaked-key", + "X-GoModel-User-Path": "/internal", + "X-Safe": "ok", + }, + } + provider := NewWithHTTPClient("vllm-key", server.URL+"/v1", server.Client(), llmclient.Hooks{}, cfg, "") + + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: "tokenize", + Body: io.NopCloser(strings.NewReader("{}")), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + // Provider-set Authorization is still applied (setHeaders runs first). + if gotAuth != "Bearer vllm-key" { + t.Errorf("Authorization = %q, want Bearer vllm-key", gotAuth) + } + if gotAPIKey != "" { + t.Errorf("X-Api-Key = %q, want empty (blocked)", gotAPIKey) + } + if gotInternal != "" { + t.Errorf("X-GoModel-User-Path = %q, want empty (blocked)", gotInternal) + } + if gotSafe != "ok" { + t.Errorf("X-Safe = %q, want ok", gotSafe) + } +} + +// TestPassthrough_Root_StaticHeaders_RespectsUserPathAlias verifies that the +// configured user-path alias is blocked on the non-v1 root passthrough client. +func TestPassthrough_Root_StaticHeaders_RespectsUserPathAlias(t *testing.T) { + var gotAlias, gotOther string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAlias = r.Header.Get("X-My-Alias") + gotOther = r.Header.Get("X-Other") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-My-Alias": "secret", + "X-Other": "ok", + }, + } + provider := NewWithHTTPClient("vllm-key", server.URL+"/v1", server.Client(), llmclient.Hooks{}, cfg, "X-My-Alias") + + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: "tokenize", + Body: io.NopCloser(strings.NewReader("{}")), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + if gotAlias != "" { + t.Errorf("X-My-Alias = %q, want empty (blocked by alias)", gotAlias) + } + if gotOther != "ok" { + t.Errorf("X-Other = %q, want ok", gotOther) + } +} + +// TestPassthrough_Root_AppliesPassthroughUserHeaders verifies that passthrough +// mode applies user-supplied headers from context to the non-v1 root +// passthrough client while still blocking credentials and the hard-coded +// X-GoModel-User-Path floor. +func TestPassthrough_Root_AppliesPassthroughUserHeaders(t *testing.T) { + var gotCustom, gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCustom = r.Header.Get("X-User-Custom") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + PassthroughUserHeaders: true, + } + provider := NewWithHTTPClient("vllm-key", server.URL+"/v1", server.Client(), llmclient.Hooks{}, cfg, "X-GoModel-User-Path") + + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-User-Custom": {"user-value"}, + "X-Other-Pass": {"other-value"}, + "Authorization": {"Bearer leaked"}, + "X-GoModel-User-Path": {"/internal"}, + }) + + resp, err := provider.Passthrough(ctx, &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: "tokenize", + Body: io.NopCloser(strings.NewReader("{}")), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + if gotCustom != "user-value" { + t.Errorf("X-User-Custom = %q, want user-value", gotCustom) + } + // Auth must be the provider's, not the leaked user one. + if gotAuth != "Bearer vllm-key" { + t.Errorf("Authorization = %q, want Bearer vllm-key", gotAuth) + } +} + +// TestPassthrough_Root_PassthroughHeaders_AppliesSkipList verifies that the +// skip list filters passthrough headers on the non-v1 root passthrough client. +func TestPassthrough_Root_PassthroughHeaders_AppliesSkipList(t *testing.T) { + var gotKept, gotSkipped string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKept = r.Header.Get("X-Keep-Me") + gotSkipped = r.Header.Get("X-Skip-Me") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Skip-Me"}, + SkipMode: "skip", + } + provider := NewWithHTTPClient("vllm-key", server.URL+"/v1", server.Client(), llmclient.Hooks{}, cfg, "") + + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-Keep-Me": {"keep"}, + "X-Skip-Me": {"drop"}, + }) + + resp, err := provider.Passthrough(ctx, &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: "tokenize", + Body: io.NopCloser(strings.NewReader("{}")), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + + if gotKept != "keep" { + t.Errorf("X-Keep-Me = %q, want keep", gotKept) + } + if gotSkipped != "" { + t.Errorf("X-Skip-Me = %q, want empty (skipped)", gotSkipped) + } +} + +// TestNew_FactoryWiringPassesHeaderOverridesAndUserPathAlias verifies the +// factory constructor wires HeaderOverrides and UserPathHeader from +// ProviderOptions into both the OpenAI-compatible client and the non-v1 root +// passthrough client. We go through `New` (returns core.Provider) and assert to +// the concrete *Provider so we can call Passthrough on it. +func TestNew_FactoryWiringPassesHeaderOverridesAndUserPathAlias(t *testing.T) { + var chatGotCustom, chatGotAuth string + var rootGotCustom string + + mux := http.NewServeMux() + // OpenAI-compatible (v1) client. + mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + chatGotCustom = r.Header.Get("X-Provider-Custom") + chatGotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-vllm", + "created":1677652288, + "model":"Qwen/Qwen2.5-0.5B-Instruct", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + }) + // Root passthrough client (no /v1 prefix). + mux.HandleFunc("/tokenize", func(w http.ResponseWriter, r *http.Request) { + rootGotCustom = r.Header.Get("X-Provider-Custom") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tokens":[1,2,3]}`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + cfg := providers.ProviderConfig{ + Type: "vllm", + APIKey: "vllm-key", + BaseURL: server.URL + "/v1", + } + opts := providers.ProviderOptions{ + UserPathHeader: "X-GoModel-User-Path", + HeaderOverrides: &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Custom": "factory-value", + }, + }, + } + + provider, ok := New(cfg, opts).(*Provider) + if !ok { + t.Fatalf("New() did not return *vllm.Provider, got %T", provider) + } + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "Qwen/Qwen2.5-0.5B-Instruct", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if chatGotCustom != "factory-value" { + t.Errorf("chat X-Provider-Custom = %q, want factory-value", chatGotCustom) + } + if chatGotAuth != "Bearer vllm-key" { + t.Errorf("chat Authorization = %q, want Bearer vllm-key", chatGotAuth) + } + + resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: "tokenize", + Body: io.NopCloser(strings.NewReader("{}")), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + if err != nil { + t.Fatalf("Passthrough() error = %v", err) + } + defer resp.Body.Close() + if rootGotCustom != "factory-value" { + t.Errorf("root X-Provider-Custom = %q, want factory-value", rootGotCustom) + } +} diff --git a/internal/providers/xai/xai_test.go b/internal/providers/xai/xai_test.go index 2854d081..cba50f74 100644 --- a/internal/providers/xai/xai_test.go +++ b/internal/providers/xai/xai_test.go @@ -17,7 +17,7 @@ import ( func TestNew(t *testing.T) { apiKey := "test-api-key" // Use NewWithHTTPClient to get concrete type for internal testing - provider := NewWithHTTPClient(apiKey, nil, llmclient.Hooks{}) + provider := NewWithHTTPClient(apiKey, nil, llmclient.Hooks{}, nil, "") if provider.apiKey != apiKey { t.Errorf("apiKey = %q, want %q", provider.apiKey, apiKey) @@ -83,7 +83,7 @@ func TestNewWithHTTPClient(t *testing.T) { } // Create provider with custom HTTP client - provider := NewWithHTTPClient("test-api-key", customClient, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", customClient, llmclient.Hooks{}, nil, "") // Verify provider is non-nil if provider == nil { @@ -147,7 +147,7 @@ func TestChatCompletion_ForwardsXGrokConvIDFromSnapshot(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) ctx := core.WithRequestSnapshot(context.Background(), core.NewRequestSnapshot( @@ -196,7 +196,7 @@ func TestChatCompletion_GeneratesStableXGrokConvIDWhenMissing(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) initialMessages := []core.Message{ @@ -244,7 +244,7 @@ func TestStreamChatCompletion_ForwardsXGrokConvIDFromSnapshot(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) ctx := core.WithRequestSnapshot(context.Background(), core.NewRequestSnapshot( @@ -376,7 +376,7 @@ func TestChatCompletion(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ChatRequest{ @@ -460,7 +460,7 @@ data: [DONE] })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ChatRequest{ @@ -572,7 +572,7 @@ func TestListModels(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) resp, err := provider.ListModels(context.Background()) @@ -601,7 +601,7 @@ func TestChatCompletionWithContext(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) ctx, cancel := context.WithCancel(context.Background()) @@ -742,7 +742,7 @@ func TestResponses(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ResponsesRequest{ @@ -865,7 +865,7 @@ data: {"type":"response.completed","response":{"id":"resp_123","object":"respons })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) req := &core.ResponsesRequest{ @@ -899,7 +899,7 @@ func TestResponsesWithContext(t *testing.T) { })) defer server.Close() - provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}) + provider := NewWithHTTPClient("test-api-key", nil, llmclient.Hooks{}, nil, "") provider.SetBaseURL(server.URL) ctx, cancel := context.WithCancel(context.Background()) @@ -915,3 +915,272 @@ func TestResponsesWithContext(t *testing.T) { t.Error("expected error when context is cancelled, got nil") } } + +func TestChatCompletion_AppliesStaticCustomHeaders(t *testing.T) { + var gotRegion, gotTrace string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRegion = r.Header.Get("X-Provider-Region") + gotTrace = r.Header.Get("X-Trace-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-xai", + "created":1677652288, + "model":"grok-2", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Region": "us-east-1", + "X-Trace-Id": "trace-abc", + }, + } + provider := NewWithHTTPClient("xai-key", server.Client(), llmclient.Hooks{}, cfg, "") + provider.SetBaseURL(server.URL) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "grok-2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotRegion != "us-east-1" { + t.Errorf("X-Provider-Region = %q, want us-east-1", gotRegion) + } + if gotTrace != "trace-abc" { + t.Errorf("X-Trace-Id = %q, want trace-abc", gotTrace) + } +} + +func TestChatCompletion_StaticHeaders_BlocksCredentialAndInternal(t *testing.T) { + var gotAuth, gotAPIKey, gotInternal, gotSafe string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotAPIKey = r.Header.Get("X-Api-Key") + gotInternal = r.Header.Get("X-GoModel-User-Path") + gotSafe = r.Header.Get("X-Safe") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-xai", + "created":1677652288, + "model":"grok-2", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "Authorization": "Bearer leaked", + "X-Api-Key": "leaked-key", + "X-GoModel-User-Path": "/internal", + "X-Safe": "ok", + }, + } + provider := NewWithHTTPClient("xai-key", server.Client(), llmclient.Hooks{}, cfg, "") + provider.SetBaseURL(server.URL) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "grok-2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + // Provider-set Authorization is still applied (setHeaders runs first). + if gotAuth != "Bearer xai-key" { + t.Errorf("Authorization = %q, want Bearer xai-key", gotAuth) + } + if gotAPIKey != "" { + t.Errorf("X-Api-Key = %q, want empty (blocked)", gotAPIKey) + } + if gotInternal != "" { + t.Errorf("X-GoModel-User-Path = %q, want empty (blocked)", gotInternal) + } + if gotSafe != "ok" { + t.Errorf("X-Safe = %q, want ok", gotSafe) + } +} + +func TestChatCompletion_StaticHeaders_RespectsUserPathAlias(t *testing.T) { + var gotAlias, gotOther string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAlias = r.Header.Get("X-My-Alias") + gotOther = r.Header.Get("X-Other") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-xai", + "created":1677652288, + "model":"grok-2", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-My-Alias": "secret", + "X-Other": "ok", + }, + } + provider := NewWithHTTPClient("xai-key", server.Client(), llmclient.Hooks{}, cfg, "X-My-Alias") + provider.SetBaseURL(server.URL) + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "grok-2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotAlias != "" { + t.Errorf("X-My-Alias = %q, want empty (blocked by alias)", gotAlias) + } + if gotOther != "ok" { + t.Errorf("X-Other = %q, want ok", gotOther) + } +} + +func TestChatCompletion_PassthroughUserHeaders(t *testing.T) { + var gotCustom, gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCustom = r.Header.Get("X-User-Custom") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-xai", + "created":1677652288, + "model":"grok-2", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + PassthroughUserHeaders: true, + } + provider := NewWithHTTPClient("xai-key", server.Client(), llmclient.Hooks{}, cfg, "X-GoModel-User-Path") + provider.SetBaseURL(server.URL) + + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-User-Custom": {"user-value"}, + "X-Other-Pass": {"other-value"}, + "X-Skip-Me": {"nope"}, + "Authorization": {"Bearer leaked"}, + "X-GoModel-User-Path": {"/internal"}, + }) + + if _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "grok-2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotCustom != "user-value" { + t.Errorf("X-User-Custom = %q, want user-value", gotCustom) + } + // Auth must be the provider's, not the leaked user one. + if gotAuth != "Bearer xai-key" { + t.Errorf("Authorization = %q, want Bearer xai-key", gotAuth) + } +} + +func TestChatCompletion_PassthroughHeaders_AppliesSkipList(t *testing.T) { + var gotKept, gotSkipped string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKept = r.Header.Get("X-Keep-Me") + gotSkipped = r.Header.Get("X-Skip-Me") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-xai", + "created":1677652288, + "model":"grok-2", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := &providers.HeaderOverridesConfig{ + PassthroughUserHeaders: true, + SkipHeaders: []string{"X-Skip-Me"}, + SkipMode: "skip", + } + provider := NewWithHTTPClient("xai-key", server.Client(), llmclient.Hooks{}, cfg, "") + provider.SetBaseURL(server.URL) + + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-Keep-Me": {"keep"}, + "X-Skip-Me": {"drop"}, + }) + + if _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "grok-2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotKept != "keep" { + t.Errorf("X-Keep-Me = %q, want keep", gotKept) + } + if gotSkipped != "" { + t.Errorf("X-Skip-Me = %q, want empty (skipped)", gotSkipped) + } +} + +func TestNew_FactoryWiringPassesHeaderOverridesAndUserPathAlias(t *testing.T) { + var gotCustom, gotAlias string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCustom = r.Header.Get("X-Provider-Custom") + gotAlias = r.Header.Get("X-GoModel-User-Path") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-xai", + "created":1677652288, + "model":"grok-2", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + cfg := providers.ProviderConfig{ + Type: "xai", + APIKey: "xai-key", + BaseURL: server.URL, + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Custom": "factory-value", + }, + } + opts := providers.ProviderOptions{ + UserPathHeader: "X-GoModel-User-Path", + HeaderOverrides: &providers.HeaderOverridesConfig{ + CustomUpstreamHeaders: map[string]string{ + "X-Provider-Custom": "factory-value", + }, + }, + } + + provider := New(cfg, opts) + ctx := providers.WithPassthroughHeaders(context.Background(), http.Header{ + "X-GoModel-User-Path": {"/v1/chat/completions"}, + }) + + if _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "grok-2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotCustom != "factory-value" { + t.Errorf("X-Provider-Custom = %q, want factory-value", gotCustom) + } + // X-GoModel-User-Path is hard-coded blocked; alias test ensures it doesn't leak. + if gotAlias != "" { + t.Errorf("X-GoModel-User-Path = %q, want empty (blocked)", gotAlias) + } +} diff --git a/internal/server/http_test.go b/internal/server/http_test.go index a2917101..f6fdee4c 100644 --- a/internal/server/http_test.go +++ b/internal/server/http_test.go @@ -6,12 +6,14 @@ import ( "net" "net/http" "net/http/httptest" + "sort" "strings" "testing" "time" "gomodel/internal/admin" "gomodel/internal/admin/dashboard" + "gomodel/internal/authkeys" "gomodel/internal/core" "gomodel/internal/providers" "gomodel/internal/usage" @@ -1154,3 +1156,126 @@ func TestProviderPassthroughRoute_DisabledRequiresAuthBefore404(t *testing.T) { t.Fatal("passthrough handler should not be invoked when provider passthrough is disabled") } } + +// passthroughProbe returns the filtered headers attached to the request context +// by PassthroughHeaderCapture. A nil result means the middleware never ran. +func passthroughProbe(c *echo.Context) error { + headers := providers.PassthroughHeadersFromContext(c.Request().Context()) + if headers == nil { + return c.String(http.StatusNoContent, "") + } + // Encode the captured headers as a deterministic probe string so tests can + // assert against the exact set without exposing internals. + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(headers.Get(k)) + b.WriteByte('\n') + } + return c.String(http.StatusOK, b.String()) +} + +func TestNewServer_RegistersPassthroughMiddleware(t *testing.T) { + mock := &mockProvider{} + srv := New(mock, &Config{PassthroughUserHeadersEnabled: true}) + srv.echo.GET("/__passthrough_probe", passthroughProbe) + + req := httptest.NewRequest(http.MethodGet, "/__passthrough_probe", nil) + req.Header.Set("X-Tenant-Id", "acme") + req.Header.Set("Authorization", "Bearer should-be-blocked") + req.Header.Set("X-Custom", "hello") + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%q", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "X-Tenant-Id=acme") { + t.Errorf("expected X-Tenant-Id captured; got body=%q", body) + } + if !strings.Contains(body, "X-Custom=hello") { + t.Errorf("expected X-Custom captured; got body=%q", body) + } + // Credential header must never reach the context. + for _, blocked := range []string{"Authorization"} { + if strings.Contains(body, blocked+"=") { + t.Errorf("credential header %q leaked into passthrough context: %q", blocked, body) + } + } +} + +func TestNewServer_SkipsPassthroughMiddleware_WhenDisabled(t *testing.T) { + mock := &mockProvider{} + srv := New(mock, nil) // PassthroughUserHeadersEnabled left at zero value (false) + srv.echo.GET("/__passthrough_probe", passthroughProbe) + + req := httptest.NewRequest(http.MethodGet, "/__passthrough_probe", nil) + req.Header.Set("X-Tenant-Id", "acme") + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + // 204 signals the probe saw no captured headers — middleware never ran. + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204 (no captured headers); body=%q", rec.Code, rec.Body.String()) + } + if rec.Body.Len() != 0 { + t.Errorf("expected empty body, got %q", rec.Body.String()) + } +} + +// passthroughOrderingAuthenticator captures the passthrough headers that +// PassthroughHeaderCapture stored on the request context before AuthMiddleware +// ran. The capture happens inside Authenticate, which the middleware invokes +// only after RequestSnapshotCapture and PassthroughHeaderCapture have already +// populated the context. +type passthroughOrderingAuthenticator struct { + enabled bool + capturedHeaders http.Header +} + +func (a *passthroughOrderingAuthenticator) Enabled() bool { return a.enabled } + +func (a *passthroughOrderingAuthenticator) Authenticate(ctx context.Context, _ string) (authkeys.AuthenticationResult, error) { + a.capturedHeaders = providers.PassthroughHeadersFromContext(ctx) + return authkeys.AuthenticationResult{ID: "ok"}, nil +} + +func TestNewServer_PassthroughMiddlewareRunsBeforeAuth(t *testing.T) { + mock := &mockProvider{ + modelsResponse: &core.ModelsResponse{Object: "list"}, + } + auth := &passthroughOrderingAuthenticator{enabled: true} + srv := New(mock, &Config{ + PassthroughUserHeadersEnabled: true, + Authenticator: auth, + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Header.Set("X-Tenant-Id", "acme") + req.Header.Set("Authorization", "Bearer managed-token") + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%q", rec.Code, rec.Body.String()) + } + if auth.capturedHeaders == nil { + t.Fatal("expected passthrough headers to be visible to AuthMiddleware") + } + if got := auth.capturedHeaders.Get("X-Tenant-Id"); got != "acme" { + t.Errorf("captured X-Tenant-Id = %q, want %q", got, "acme") + } + if auth.capturedHeaders.Get("Authorization") != "" { + t.Errorf("Authorization must be filtered before AuthMiddleware sees it; got %q", auth.capturedHeaders.Get("Authorization")) + } +} diff --git a/internal/server/passthrough_headers_test.go b/internal/server/passthrough_headers_test.go new file mode 100644 index 00000000..c88e6b37 --- /dev/null +++ b/internal/server/passthrough_headers_test.go @@ -0,0 +1,160 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + + "gomodel/internal/providers" +) + +// runPassthroughMiddleware wires PassthroughHeaderCapture around a probe handler +// that records what the middleware stored on the request context. It returns the +// recorder so tests can inspect the HTTP response, and exposes the captured +// headers via a side channel (the probe populates a closure variable). +func runPassthroughMiddleware(t *testing.T, alias string, setup func(*http.Request)) (headers http.Header, nextCalled bool, rec *httptest.ResponseRecorder) { + t.Helper() + + e := echo.New() + captured := struct { + headers http.Header + nextCalled bool + }{} + + handler := func(c *echo.Context) error { + captured.headers = providers.PassthroughHeadersFromContext(c.Request().Context()) + captured.nextCalled = true + return c.String(http.StatusOK, "ok") + } + + mw := PassthroughHeaderCapture(alias) + wrapped := mw(handler) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + if setup != nil { + setup(req) + } + rec = httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := wrapped(c); err != nil { + t.Fatalf("middleware returned error: %v", err) + } + + return captured.headers, captured.nextCalled, rec +} + +func TestPassthroughHeaderCapture_StoresFilteredHeaders(t *testing.T) { + headers, _, _ := runPassthroughMiddleware(t, "", func(req *http.Request) { + req.Header.Set("X-Tenant-Id", "acme") + req.Header.Set("X-Custom", "hello") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer should-be-blocked") + req.Header.Set("X-Api-Key", "blocked-key") + }) + + if headers == nil { + t.Fatal("expected headers in context, got nil") + } + if got := headers.Get("X-Tenant-Id"); got != "acme" { + t.Errorf("X-Tenant-Id = %q, want %q", got, "acme") + } + if got := headers.Get("X-Custom"); got != "hello" { + t.Errorf("X-Custom = %q, want %q", got, "hello") + } + if got := headers.Get("Accept"); got != "application/json" { + t.Errorf("Accept = %q, want %q", got, "application/json") + } +} + +func TestPassthroughHeaderCapture_StripsCredentials(t *testing.T) { + headers, _, _ := runPassthroughMiddleware(t, "", func(req *http.Request) { + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("X-Api-Key", "k") + req.Header.Set("X-GoModel-User-Path", "/v1/x") + req.Header.Set("Cookie", "session=secret") + req.Header.Set("X-Tenant-Id", "acme") // survivor + }) + + if headers == nil { + t.Fatal("expected headers in context, got nil") + } + // Credentials and internal headers must not appear in the captured map. + for _, blocked := range []string{"Authorization", "X-Api-Key", "X-GoModel-User-Path", "Cookie"} { + if _, present := headers[blocked]; present { + t.Errorf("blocked header %q leaked into passthrough context: %v", blocked, headers[blocked]) + } + } + // Non-credential header should still be present. + if got := headers.Get("X-Tenant-Id"); got != "acme" { + t.Errorf("X-Tenant-Id = %q, want %q", got, "acme") + } +} + +func TestPassthroughHeaderCapture_StripsConfiguredUserPathAlias(t *testing.T) { + headers, _, _ := runPassthroughMiddleware(t, "X-Tenant-Path", func(req *http.Request) { + req.Header.Set("X-Tenant-Path", "/v1/secret") + req.Header.Set("X-Tenant-Id", "acme") + }) + + if headers == nil { + t.Fatal("expected headers in context, got nil") + } + if _, present := headers["X-Tenant-Path"]; present { + t.Errorf("user path alias leaked into passthrough context: %v", headers["X-Tenant-Path"]) + } + if got := headers.Get("X-Tenant-Id"); got != "acme" { + t.Errorf("X-Tenant-Id = %q, want %q", got, "acme") + } +} + +func TestPassthroughHeaderCapture_PassesThrough(t *testing.T) { + _, nextCalled, rec := runPassthroughMiddleware(t, "", func(req *http.Request) { + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("X-Tenant-Id", "acme") + }) + + if !nextCalled { + t.Fatal("middleware did not call next handler") + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "ok" { + t.Errorf("body = %q, want %q", body, "ok") + } +} + +func TestPassthroughHeaderCapture_DoesNotMutateOriginalHeaders(t *testing.T) { + e := echo.New() + var observed http.Header + + handler := func(c *echo.Context) error { + observed = c.Request().Header.Clone() + return c.String(http.StatusOK, "ok") + } + + mw := PassthroughHeaderCapture("") + wrapped := mw(handler) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("X-Tenant-Id", "acme") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := wrapped(c); err != nil { + t.Fatalf("middleware returned error: %v", err) + } + + // Original request headers must still carry credentials — the middleware + // must not strip the wire-level headers, only the context copy. + if got := observed.Get("Authorization"); got != "Bearer secret" { + t.Errorf("original Authorization mutated: %q", got) + } + if got := observed.Get("X-Tenant-Id"); got != "acme" { + t.Errorf("original X-Tenant-Id lost: %q", got) + } +} diff --git a/tests/contract/xai_test.go b/tests/contract/xai_test.go index 34a784d2..3b12b821 100644 --- a/tests/contract/xai_test.go +++ b/tests/contract/xai_test.go @@ -19,7 +19,7 @@ func newXAIReplayProvider(t *testing.T, routes map[string]replayRoute) core.Prov t.Helper() client := newReplayHTTPClient(t, routes) - provider := xai.NewWithHTTPClient("xai-test", client, llmclient.Hooks{}) + provider := xai.NewWithHTTPClient("xai-test", client, llmclient.Hooks{}, nil, "") provider.SetBaseURL("https://replay.local") return provider } From c3d52141e6b1f9b893898cd54c5f750ca0e3c58d Mon Sep 17 00:00:00 2001 From: weselben Date: Sun, 5 Jul 2026 00:07:19 +0200 Subject: [PATCH 4/5] feat(provider): add Moonshot Kimi provider Register the Kimi provider, add an OpenAI-compatible adapter, and enable configuration in examples, Helm values, and the default passthrough provider list. --- config/server.go | 2 +- helm/values.yaml | 10 ++++++- internal/providers/kimi/kimi.go | 49 +++++++++++++++++++++++++++++++++ run/providers.go | 2 ++ run/providers_test.go | 2 +- 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 internal/providers/kimi/kimi.go diff --git a/config/server.go b/config/server.go index 9059d24a..e71d757e 100644 --- a/config/server.go +++ b/config/server.go @@ -37,7 +37,7 @@ type ServerConfig struct { UserPathHeader string `yaml:"user_path_header" env:"USER_PATH_HEADER"` // EnabledPassthroughProviders lists the provider types enabled on // /p/{provider}/... passthrough routes. Default: - // ["openai", "anthropic", "openrouter", "zai", "vllm", "deepseek"]. + // ["openai", "anthropic", "openrouter", "zai", "vllm", "deepseek", "bailian", "kimi"]. EnabledPassthroughProviders []string `yaml:"enabled_passthrough_providers" env:"ENABLED_PASSTHROUGH_PROVIDERS"` // RealtimeEnabled exposes the realtime (speech-to-speech) websocket endpoint // at /v1/realtime and the /p/{provider}/v1/realtime passthrough upgrade. diff --git a/helm/values.yaml b/helm/values.yaml index ce342fe4..d021f0c3 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -42,7 +42,7 @@ auth: # LLM Provider configuration providers: # -- Use an existing secret for all provider API keys - # Secret should contain keys: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, XAI_API_KEY, ZAI_API_KEY, ORACLE_API_KEY, VLLM_API_KEY + # Secret should contain keys: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, XAI_API_KEY, ZAI_API_KEY, ORACLE_API_KEY, VLLM_API_KEY, KIMI_API_KEY existingSecret: "" openai: @@ -111,6 +111,14 @@ providers: # -- vLLM OpenAI-compatible base URL baseUrl: "" + kimi: + # -- Enable Kimi provider (auto-enabled if apiKey is set) + enabled: false + # -- Kimi API key (ignored if providers.existingSecret is set) + apiKey: "" + # -- Optional: Override Kimi base URL (default: https://api.kimi.com/coding/v1) + baseUrl: "" + # Cache configuration cache: redis: diff --git a/internal/providers/kimi/kimi.go b/internal/providers/kimi/kimi.go new file mode 100644 index 00000000..284477b5 --- /dev/null +++ b/internal/providers/kimi/kimi.go @@ -0,0 +1,49 @@ +// Package kimi provides Kimi API integration for the LLM gateway. +package kimi + +import ( + "net/http" + + "gomodel/internal/core" + "gomodel/internal/llmclient" + "gomodel/internal/providers" + "gomodel/internal/providers/openai" +) + +const defaultBaseURL = "https://api.kimi.com/coding/v1" + +// Registration provides factory registration for the Kimi provider. +var Registration = providers.Registration{ + Type: "kimi", + New: New, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + }, +} + +// Provider implements the core.Provider interface for Kimi. +type Provider struct { + *openai.ChatCompatible +} + +var _ core.Provider = (*Provider)(nil) + +// New creates a new Kimi provider. +func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { + return &Provider{openai.NewChatCompatible(cfg.APIKey, opts, openai.CompatibleProviderConfig{ + ProviderName: "kimi", + BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), + })} +} + +// NewWithHTTPClient creates a new Kimi provider with a custom HTTP client. +// If httpClient is nil, http.DefaultClient is used. headerOverrides and +// userPathAlias are optional; pass nil and "" to disable header overrides. +func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks, headerOverrides *providers.HeaderOverridesConfig, userPathAlias string) *Provider { + return &Provider{openai.NewChatCompatibleWithHTTPClient(apiKey, httpClient, hooks, openai.CompatibleProviderConfig{ + ProviderName: "kimi", + BaseURL: providers.ResolveBaseURL(baseURL, defaultBaseURL), + HeaderOverrides: headerOverrides, + UserPathAlias: userPathAlias, + })} +} \ No newline at end of file diff --git a/run/providers.go b/run/providers.go index 67b3b6eb..a5276689 100644 --- a/run/providers.go +++ b/run/providers.go @@ -12,6 +12,7 @@ import ( "gomodel/internal/providers/fireworks" "gomodel/internal/providers/gemini" "gomodel/internal/providers/groq" + "gomodel/internal/providers/kimi" "gomodel/internal/providers/minimax" "gomodel/internal/providers/ollama" "gomodel/internal/providers/openai" @@ -53,6 +54,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(xai.Registration) factory.Add(xiaomi.Registration) factory.Add(zai.Registration) + factory.Add(kimi.Registration) return factory } diff --git a/run/providers_test.go b/run/providers_test.go index 63a33754..4e93dc71 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -10,7 +10,7 @@ import ( func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ "anthropic", "azure", "bailian", "bedrock", "deepseek", "fireworks", - "gemini", "groq", "minimax", "ollama", "openai", "opencode_go", + "gemini", "groq", "kimi", "minimax", "ollama", "openai", "opencode_go", "openrouter", "oracle", "vertex", "vllm", "xai", "xiaomi", "zai", } From 0503eb72f1b3cdddbee61c5a8d1c0f6c54a96e20 Mon Sep 17 00:00:00 2001 From: weselben Date: Sun, 5 Jul 2026 00:07:57 +0200 Subject: [PATCH 5/5] tests(provider): add Kimi contract tests, golden fixtures and recordapi support Add Kimi unit and contract tests, recorded API fixtures, golden files, and recordapi tooling for generating Kimi testdata. --- cmd/recordapi/main.go | 51 +- cmd/recordapi/main_test.go | 80 ++ config/config_example_test.go | 99 ++ config/config_test.go | 2 +- internal/providers/kimi/kimi_test.go | 148 +++ run/lifecycle_test.go | 79 ++ tests/contract/kimi_test.go | 107 ++ .../golden/kimi/chat_completion.golden.json | 23 + .../kimi/chat_completion_stream.golden.json | 794 +++++++++++++ .../golden/kimi/chat_with_params.golden.json | 23 + .../golden/kimi/chat_with_tools.golden.json | 23 + .../golden/kimi/embeddings.golden.json | 1041 +++++++++++++++++ .../testdata/golden/kimi/models.golden.json | 11 + .../testdata/kimi/chat_completion.json | 22 + .../testdata/kimi/chat_completion_stream.txt | 106 ++ .../testdata/kimi/chat_with_params.json | 22 + .../testdata/kimi/chat_with_tools.json | 22 + tests/contract/testdata/kimi/embeddings.json | 1035 ++++++++++++++++ tests/contract/testdata/kimi/models.json | 21 + 19 files changed, 3701 insertions(+), 8 deletions(-) create mode 100644 cmd/recordapi/main_test.go create mode 100644 internal/providers/kimi/kimi_test.go create mode 100644 tests/contract/kimi_test.go create mode 100644 tests/contract/testdata/golden/kimi/chat_completion.golden.json create mode 100644 tests/contract/testdata/golden/kimi/chat_completion_stream.golden.json create mode 100644 tests/contract/testdata/golden/kimi/chat_with_params.golden.json create mode 100644 tests/contract/testdata/golden/kimi/chat_with_tools.golden.json create mode 100644 tests/contract/testdata/golden/kimi/embeddings.golden.json create mode 100644 tests/contract/testdata/golden/kimi/models.golden.json create mode 100644 tests/contract/testdata/kimi/chat_completion.json create mode 100644 tests/contract/testdata/kimi/chat_completion_stream.txt create mode 100644 tests/contract/testdata/kimi/chat_with_params.json create mode 100644 tests/contract/testdata/kimi/chat_with_tools.json create mode 100644 tests/contract/testdata/kimi/embeddings.json create mode 100644 tests/contract/testdata/kimi/models.json diff --git a/cmd/recordapi/main.go b/cmd/recordapi/main.go index 44969438..bb781cbd 100644 --- a/cmd/recordapi/main.go +++ b/cmd/recordapi/main.go @@ -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", @@ -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", @@ -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{ @@ -137,6 +164,9 @@ var providerCapabilities = map[string]map[string]bool{ "xai": { "responses": true, }, + "kimi": { + "responses": false, + }, "oracle": { "responses": true, }, @@ -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() @@ -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 @@ -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) diff --git a/cmd/recordapi/main_test.go b/cmd/recordapi/main_test.go new file mode 100644 index 00000000..6d595ace --- /dev/null +++ b/cmd/recordapi/main_test.go @@ -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) + } + } +} diff --git a/config/config_example_test.go b/config/config_example_test.go index 0a659423..5996d1bf 100644 --- a/config/config_example_test.go +++ b/config/config_example_test.go @@ -2,6 +2,8 @@ package config import ( "os" + "path/filepath" + "reflect" "testing" ) @@ -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 +} diff --git a/config/config_test.go b/config/config_test.go index 27d1a91f..20c99769 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) } diff --git a/internal/providers/kimi/kimi_test.go b/internal/providers/kimi/kimi_test.go new file mode 100644 index 00000000..0030173e --- /dev/null +++ b/internal/providers/kimi/kimi_test.go @@ -0,0 +1,148 @@ +package kimi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gomodel/internal/core" + "gomodel/internal/llmclient" + "gomodel/internal/providers" +) + +func TestNew_DefaultBaseURL(t *testing.T) { + // Kimi.New routes through the provider factory wiring. Without a configured + // BaseURL, the resolver must fall back to the package-level default. + provider := New(providers.ProviderConfig{ + Type: "kimi", + APIKey: "kimi-key", + }, providers.ProviderOptions{}) + if provider == nil { + t.Fatal("New() returned nil provider") + } + + kp, ok := provider.(*Provider) + if !ok { + t.Fatalf("New() returned %T, want *Provider", provider) + } + if got := kp.GetBaseURL(); got != defaultBaseURL { + t.Fatalf("GetBaseURL() = %q, want %q", got, defaultBaseURL) + } +} + +func TestNew_CustomBaseURL(t *testing.T) { + // NewWithHTTPClient honors an explicit BaseURL via the resolver. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-custom", + "created":1677652288, + "model":"kimi-k2", + "choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("kimi-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") + if got := provider.GetBaseURL(); got != server.URL { + t.Fatalf("GetBaseURL() = %q, want %q", got, server.URL) + } + + // Sanity-check: requests actually flow to the custom URL. + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "kimi-k2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } +} + +func TestNewWithHTTPClient_BearerAuth(t *testing.T) { + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-bearer", + "created":1677652288, + "model":"kimi-k2", + "choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("kimi-secret", server.URL, server.Client(), llmclient.Hooks{}, nil, "") + + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "kimi-k2", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + + if gotAuth != "Bearer kimi-secret" { + t.Fatalf("authorization = %q, want %q", gotAuth, "Bearer kimi-secret") + } +} + +func TestProvider_SatisfiesInterface(t *testing.T) { + // Compile-time check (mirrors the package-level assertion in kimi.go). + var _ core.Provider = (*Provider)(nil) + + // Runtime check: New returns something that satisfies core.Provider. + provider := New(providers.ProviderConfig{ + Type: "kimi", + APIKey: "kimi-key", + }, providers.ProviderOptions{}) + if provider == nil { + t.Fatal("New() returned nil") + } + + // Kimi does not expose optional native batch or file provider surfaces. + if _, ok := any(provider).(core.NativeBatchProvider); ok { + t.Fatal("kimi provider should not implement NativeBatchProvider") + } + if _, ok := any(provider).(core.NativeFileProvider); ok { + t.Fatal("kimi provider should not implement NativeFileProvider") + } +} + +func TestEmbeddings_DelegatesToCompatibleProvider(t *testing.T) { + // Kimi has no provider-specific Embeddings override, so calls delegate + // through the embedded openai.ChatCompatible adapter to /embeddings. + var gotPath string + var gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "object":"list", + "model":"kimi-embedding", + "data":[{"object":"embedding","embedding":[0.1,0.2,0.3],"index":0}], + "usage":{"prompt_tokens":2,"total_tokens":2} + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("kimi-key", server.URL, server.Client(), llmclient.Hooks{}, nil, "") + + resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ + Model: "kimi-embedding", + Input: "hello", + }) + if err != nil { + t.Fatalf("Embeddings() error = %v", err) + } + if resp.Model != "kimi-embedding" { + t.Fatalf("resp.Model = %q, want kimi-embedding", resp.Model) + } + if gotPath != "/embeddings" { + t.Fatalf("path = %q, want /embeddings", gotPath) + } + if gotAuth != "Bearer kimi-key" { + t.Fatalf("authorization = %q, want Bearer kimi-key", gotAuth) + } +} \ No newline at end of file diff --git a/run/lifecycle_test.go b/run/lifecycle_test.go index 5f804d7d..5323550b 100644 --- a/run/lifecycle_test.go +++ b/run/lifecycle_test.go @@ -6,6 +6,9 @@ import ( "sync" "testing" "time" + + "gomodel/internal/providers" + "gomodel/internal/providers/kimi" ) type stubLifecycleApp struct { @@ -142,3 +145,79 @@ func TestStartApplication_StopsWaitingWhenShutdownTimesOut(t *testing.T) { t.Fatalf("shutdownCalls = %d, want 1", calls) } } + +func TestMain_SetUserPathHeaderWiring(t *testing.T) { + // Verify SetUserPathHeader exists and can be called without panic + factory := providers.NewProviderFactory() + + // Test that SetUserPathHeader can be called with various valid headers + headers := []string{ + "X-GoModel-User-Path", + "X-Tenant-Path", + "X-Custom-Header", + "", // empty string should not panic + } + + for _, header := range headers { + didPanic := false + func() { + defer func() { + if r := recover(); r != nil { + didPanic = true + t.Errorf("SetUserPathHeader(%q) panicked: %v", header, r) + } + }() + factory.SetUserPathHeader(header) + }() + if didPanic { + continue + } + } +} + +func TestMain_KimiProviderRegistration(t *testing.T) { + // Mirrors the wiring done in main.go: registering kimi.Registration with a + // provider factory must succeed at startup without panicking. This is the + // unit-level equivalent of the runtime path that emits + // "provider registered" name=kimi type=kimi via initializeProviders. + factory := providers.NewProviderFactory() + + didPanic := false + func() { + defer func() { + if r := recover(); r != nil { + didPanic = true + t.Errorf("factory.Add(kimi.Registration) panicked: %v", r) + } + }() + factory.Add(kimi.Registration) + }() + if didPanic { + return + } + + // The factory must report kimi among its registered types. + registered := false + for _, t2 := range factory.RegisteredTypes() { + if t2 == "kimi" { + registered = true + break + } + } + if !registered { + t.Fatalf("RegisteredTypes() = %v, want contains \"kimi\"", factory.RegisteredTypes()) + } + + // The factory must also be able to instantiate a Kimi provider using the + // same shape that initializeProviders uses (factory.Create with type=kimi). + provider, err := factory.Create(providers.ProviderConfig{ + Type: "kimi", + APIKey: "kimi-test-key", + }) + if err != nil { + t.Fatalf("factory.Create() error = %v", err) + } + if provider == nil { + t.Fatal("factory.Create() returned nil provider") + } +} diff --git a/tests/contract/kimi_test.go b/tests/contract/kimi_test.go new file mode 100644 index 00000000..80aed7c5 --- /dev/null +++ b/tests/contract/kimi_test.go @@ -0,0 +1,107 @@ +//go:build contract + +// Contract tests in this file are intended to run with: -tags=contract -timeout=5m. +package contract + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gomodel/internal/core" + "gomodel/internal/llmclient" + "gomodel/internal/providers/kimi" +) + +func newKimiReplayProvider(t *testing.T, routes map[string]replayRoute) core.Provider { + t.Helper() + + client := newReplayHTTPClient(t, routes) + provider := kimi.NewWithHTTPClient("kimi-test", "", client, llmclient.Hooks{}, nil, "") + provider.SetBaseURL("https://replay.local") + return provider +} + +func TestKimiReplayChatCompletion(t *testing.T) { + testCases := []struct { + name string + fixturePath string + }{ + {name: "basic", fixturePath: "kimi/chat_completion.json"}, + {name: "params", fixturePath: "kimi/chat_with_params.json"}, + {name: "tools", fixturePath: "kimi/chat_with_tools.json"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + provider := newKimiReplayProvider(t, map[string]replayRoute{ + replayKey(http.MethodPost, "/chat/completions"): jsonFixtureRoute(t, tc.fixturePath), + }) + + resp, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "kimi-k2-0711", + Messages: []core.Message{{ + Role: "user", + Content: "hello", + }}, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + compareGoldenJSON(t, goldenPathForFixture(tc.fixturePath), resp) + }) + } +} + +func TestKimiReplayStreamChatCompletion(t *testing.T) { + provider := newKimiReplayProvider(t, map[string]replayRoute{ + replayKey(http.MethodPost, "/chat/completions"): sseFixtureRoute(t, "kimi/chat_completion_stream.txt"), + }) + + stream, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{ + Model: "kimi-k2-0711", + Messages: []core.Message{{ + Role: "user", + Content: "stream", + }}, + }) + require.NoError(t, err) + + raw := readAllStream(t, stream) + chunks, done := parseChatStream(t, raw) + + compareGoldenJSON(t, goldenPathForFixture("kimi/chat_completion_stream.txt"), map[string]any{ + "done": done, + "chunks": chunks, + "text": extractChatStreamText(chunks), + }) +} + +func TestKimiReplayListModels(t *testing.T) { + provider := newKimiReplayProvider(t, map[string]replayRoute{ + replayKey(http.MethodGet, "/models"): jsonFixtureRoute(t, "kimi/models.json"), + }) + + resp, err := provider.ListModels(context.Background()) + require.NoError(t, err) + require.NotNil(t, resp) + + compareGoldenJSON(t, goldenPathForFixture("kimi/models.json"), resp) +} + +func TestKimiReplayEmbeddings(t *testing.T) { + provider := newKimiReplayProvider(t, map[string]replayRoute{ + replayKey(http.MethodPost, "/embeddings"): jsonFixtureRoute(t, "kimi/embeddings.json"), + }) + + resp, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{ + Model: "kimi-embedding", + Input: "hello", + }) + require.NoError(t, err) + require.NotNil(t, resp) + + compareGoldenJSON(t, goldenPathForFixture("kimi/embeddings.json"), resp) +} \ No newline at end of file diff --git a/tests/contract/testdata/golden/kimi/chat_completion.golden.json b/tests/contract/testdata/golden/kimi/chat_completion.golden.json new file mode 100644 index 00000000..3a1b2d9d --- /dev/null +++ b/tests/contract/testdata/golden/kimi/chat_completion.golden.json @@ -0,0 +1,23 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello, World!", + "reasoning_content": "The user wants me to say \"Hello, World!\" and nothing else. I should respond with exactly that, no extra text.", + "role": "assistant" + } + } + ], + "created": 0, + "id": "chatcmpl-Ke2LzM9U97NUbz9QkHonLfXT", + "model": "kimi-for-coding", + "object": "chat.completion", + "provider": "", + "usage": { + "completion_tokens": 32, + "prompt_tokens": 18, + "total_tokens": 50 + } +} diff --git a/tests/contract/testdata/golden/kimi/chat_completion_stream.golden.json b/tests/contract/testdata/golden/kimi/chat_completion_stream.golden.json new file mode 100644 index 00000000..661c6457 --- /dev/null +++ b/tests/contract/testdata/golden/kimi/chat_completion_stream.golden.json @@ -0,0 +1,794 @@ +{ + "chunks": [ + { + "choices": [ + { + "delta": { + "content": null, + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "We" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " need" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " respond" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " to" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " user" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " The" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " user" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " said" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " \"" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "Say" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " '" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "Hello" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "," + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " World" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "!'" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " and" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " nothing" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " else" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": ".\"" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " So" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " we" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " must" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " output" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " exactly" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": ":" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " Hello" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "," + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " World" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "!" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " (" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "and" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " no" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " extra" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " text" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": ")." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " No" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " extra" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " punctuation" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " The" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " system" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " says" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " we" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " need" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " think" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " step" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " by" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": " step" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": { + "reasoning_content": "." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + }, + { + "choices": [ + { + "delta": {}, + "finish_reason": "length", + "index": 0, + "usage": { + "cached_tokens": 18, + "completion_tokens": 50, + "prompt_tokens": 18, + "prompt_tokens_details": { + "cached_tokens": 18 + }, + "total_tokens": 68 + } + } + ], + "created": 0, + "id": "chatcmpl-z5R135P3K7dLAfR674T7Aqox", + "model": "kimi-for-coding", + "object": "chat.completion.chunk" + } + ], + "done": true, + "text": "" +} diff --git a/tests/contract/testdata/golden/kimi/chat_with_params.golden.json b/tests/contract/testdata/golden/kimi/chat_with_params.golden.json new file mode 100644 index 00000000..3a1b2d9d --- /dev/null +++ b/tests/contract/testdata/golden/kimi/chat_with_params.golden.json @@ -0,0 +1,23 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello, World!", + "reasoning_content": "The user wants me to say \"Hello, World!\" and nothing else. I should respond with exactly that, no extra text.", + "role": "assistant" + } + } + ], + "created": 0, + "id": "chatcmpl-Ke2LzM9U97NUbz9QkHonLfXT", + "model": "kimi-for-coding", + "object": "chat.completion", + "provider": "", + "usage": { + "completion_tokens": 32, + "prompt_tokens": 18, + "total_tokens": 50 + } +} diff --git a/tests/contract/testdata/golden/kimi/chat_with_tools.golden.json b/tests/contract/testdata/golden/kimi/chat_with_tools.golden.json new file mode 100644 index 00000000..3a1b2d9d --- /dev/null +++ b/tests/contract/testdata/golden/kimi/chat_with_tools.golden.json @@ -0,0 +1,23 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello, World!", + "reasoning_content": "The user wants me to say \"Hello, World!\" and nothing else. I should respond with exactly that, no extra text.", + "role": "assistant" + } + } + ], + "created": 0, + "id": "chatcmpl-Ke2LzM9U97NUbz9QkHonLfXT", + "model": "kimi-for-coding", + "object": "chat.completion", + "provider": "", + "usage": { + "completion_tokens": 32, + "prompt_tokens": 18, + "total_tokens": 50 + } +} diff --git a/tests/contract/testdata/golden/kimi/embeddings.golden.json b/tests/contract/testdata/golden/kimi/embeddings.golden.json new file mode 100644 index 00000000..8eb3a286 --- /dev/null +++ b/tests/contract/testdata/golden/kimi/embeddings.golden.json @@ -0,0 +1,1041 @@ +{ + "data": [ + { + "embedding": [ + -0.040394027, + 0.037034962, + -0.028974503, + 0.016117238, + -0.03569159, + -0.040737584, + -0.055054516, + -0.04086758, + 0.0032383334, + 0.0019567884, + -0.006565444, + 0.011655461, + 0.021531276, + -0.01346186, + 0.030359978, + 0.0038854934, + 0.020515833, + -0.023150796, + -0.021936541, + -0.031283665, + -0.05100716, + -0.017397156, + 0.012335982, + -0.014076596, + 0.015406209, + 0.04133278, + -0.03686478, + -0.0005830805, + 0.00033307978, + -0.060860433, + 0.037107136, + 0.08012106, + -0.017788202, + -0.049833413, + -0.034198042, + -0.0358803, + 0.0046373233, + 0.00058047334, + -0.06894698, + 0.010874012, + 0.015922088, + 0.022586372, + 0.023137117, + -0.058908414, + 0.0022468942, + -0.04434964, + -0.040362462, + -0.0024729704, + 0.0031872925, + -0.06471843, + 0.008993335, + 0.031874534, + 0.03772882, + -0.0012027833, + 0.0034979703, + 0.022246087, + -0.016932389, + -0.023046192, + -0.062191688, + 0.002164518, + -0.02052272, + -0.0096899, + -0.0035016686, + -0.007989535, + 0.0029984, + 0.15505162, + 0.030606348, + -0.01320112, + -0.004796359, + -0.015057775, + -0.018100424, + -0.012224227, + -0.0008975755, + 0.022377588, + -0.065736115, + 0.04693246, + -0.015008559, + -0.011487965, + 0.013654111, + 0.036173884, + 0.07965084, + -0.004306148, + -0.00014813247, + 0.017645223, + -0.01340765, + 0.047996882, + 0.014968728, + 0.03673915, + -0.0043187095, + -0.01352434, + -0.037223175, + -0.023028309, + 0.027777342, + -0.051526498, + -0.028421639, + 0.016847018, + -0.005437544, + 0.0197481, + 0.036299746, + 0.033107065, + 0.048874762, + 0.018898169, + 0.009175512, + -0.013093514, + 0.0026107794, + 0.0040095476, + 0.026737425, + 0.026981097, + 0.022860466, + 0.013222615, + 0.048679523, + 0.027506452, + 0.020128995, + 0.022082394, + 0.013256789, + -0.008234297, + 0.002783527, + -0.016664824, + -0.0038981326, + -0.022203082, + -0.007607345, + 0.026623992, + 0.023318416, + 0.00966249, + 0.028936656, + -0.049664255, + 0.008967275, + 0.035097916, + -0.017560534, + -0.026824968, + 0.06391159, + 0.007055419, + -0.03429222, + 0.0045853015, + -0.0061818073, + -0.0306008, + -0.002607577, + -0.025933748, + -0.013353954, + -0.028442798, + 0.022517923, + 0.06849652, + -0.021376835, + -0.015005817, + -0.013669858, + -0.05249921, + -0.028522637, + 0.01398906, + 0.018606065, + -0.04136693, + -0.004050598, + -0.021492261, + 0.033539843, + 0.046907168, + 0.032511596, + 0.0032306416, + -0.04286484, + 0.032091957, + -0.005386527, + -0.014614995, + 0.01387069, + -0.005585015, + -0.040886365, + -0.013802158, + -0.00236522, + 0.004401568, + 0.036417205, + 0.024872579, + 0.0037468828, + 0.0037328983, + -0.012898164, + -0.009925401, + 0.02674203, + -0.020238915, + -0.015350391, + 0.023067273, + 0.05697576, + 0.024388261, + 0.01784932, + -0.036647998, + -0.04267279, + 0.006546705, + -0.0003472204, + -0.023146978, + -0.044616953, + 0.034806017, + -0.029781025, + -0.009520008, + 0.023959247, + 0.01097549, + 0.0012738897, + -0.009331952, + 0.031509046, + -0.038806263, + 0.007656794, + 0.00803978, + 0.004022515, + -0.011737894, + -0.03014297, + -0.0009863945, + -0.03323577, + -0.004283858, + -0.0042973864, + 0.07079692, + -0.06307087, + 0.00060481514, + -0.012085598, + -0.009879596, + 0.030870881, + -0.019220049, + 0.021503003, + 0.033711407, + 0.026015703, + -0.02176325, + -0.060592245, + 0.013135934, + -0.042457186, + 0.0019644122, + -0.0045424593, + -0.017918002, + 0.012551367, + -0.016391398, + -0.0049237655, + 0.018882256, + 0.014345965, + 0.006059309, + 0.0090675345, + 0.0042600576, + 0.023404974, + 0.013590409, + 0.03715393, + -0.007626446, + 0.0032882274, + -0.028024923, + -0.0087687075, + -0.018998656, + -0.055875044, + -0.0011292216, + 0.037010778, + 0.008666615, + -0.0037658575, + -0.009951769, + -0.011571954, + -0.0071347463, + -0.042702693, + 0.053818572, + -0.009404992, + 0.0132118305, + 0.0037792143, + 0.005404681, + 0.012482218, + -0.025520014, + -0.003416452, + 0.026382035, + 0.06614409, + 0.0006530235, + -0.010513645, + -0.01025331, + -0.0051495302, + -0.024383359, + 0.023428103, + -0.060133316, + 0.013606713, + 0.052414786, + 0.027262071, + 0.009798058, + -0.02319962, + -0.014810931, + 0.018317463, + 0.00853516, + 0.015639363, + -0.06762488, + -0.0108342655, + -0.019835452, + -0.008034534, + 0.016289957, + -0.012568977, + -0.04713553, + 0.020333018, + 0.012736179, + 0.042933475, + -0.003036178, + 0.03600715, + -0.017816074, + -0.0013188384, + 0.017199706, + -0.06809641, + 0.014710697, + 0.02974192, + -0.047269225, + 0.016118124, + 0.05642268, + 0.03066391, + -0.008880648, + 0.020933181, + -0.0032616272, + -0.010334366, + -0.16271424, + 0.00333371, + 0.013572324, + -0.048586458, + 0.007300261, + -0.03250149, + -0.03231623, + 0.047128834, + -0.0014050837, + 0.02005525, + -0.026136903, + -0.0011201897, + -0.037740603, + 0.032731254, + -0.020704042, + 0.02685361, + 0.009267274, + -0.035277765, + 0.05858684, + -0.056308288, + 0.0049386825, + -0.0105937775, + 0.07067127, + -0.029943945, + 0.022592425, + 0.0021007804, + 0.03539531, + -0.043204278, + -0.053544767, + 0.002181767, + -0.032549735, + 0.0116085615, + -0.0064465236, + 0.021069653, + 0.024117503, + 0.050877508, + 0.02297322, + -0.03781731, + 0.0044279965, + 0.019999074, + 0.0044124615, + 0.015705047, + 0.0051618335, + 0.023606062, + -0.018036403, + -0.04582155, + -0.036633622, + 0.004093411, + -0.014024008, + -0.0015891875, + -0.01449673, + -0.004105874, + -0.021960003, + -0.056336224, + -0.01800506, + 0.013063503, + -0.031979274, + 0.018110882, + -0.012671481, + -0.010698948, + -0.014562474, + -0.037916604, + 0.018212005, + 0.0031197541, + -0.013994167, + 0.018465186, + 0.018296309, + -0.029881733, + 0.015910452, + -0.015059606, + 0.020420842, + 0.0023686956, + 0.0035341245, + -0.032052804, + 0.016920846, + -0.0103205945, + 0.034692068, + -0.010001457, + -0.03644773, + -0.149401, + -0.028203512, + 0.0029165556, + -0.02845039, + -0.0002733007, + -0.028531313, + -0.015708257, + -0.0033854707, + 0.03938203, + 0.07356379, + 0.30598772, + 0.03341856, + 0.03246554, + -0.03010564, + 0.073812455, + -0.032426286, + 0.0052742395, + 0.004848267, + 0.00045632044, + -0.021812243, + -0.018367466, + -0.0317587, + 0.006158586, + 0.032816086, + 0.018276557, + 0.051287726, + -0.060049508, + -0.015531666, + 0.048203498, + 0.025995292, + 0.008011193, + 0.017820574, + 0.0046980176, + -0.023195772, + -0.020886628, + -0.012003315, + -0.0049633808, + 0.030865058, + 0.0070968904, + 0.021434767, + 0.007991135, + 0.0045969253, + 0.022174727, + -0.0031074013, + -0.040163044, + 0.0071089347, + 0.006755876, + -0.039214622, + -0.016654883, + 0.022388421, + -0.030040137, + -0.021064214, + 0.03000585, + 0.019120656, + 0.0416361, + 0.02542441, + 0.024422735, + -0.02592873, + -0.037245583, + 0.029054156, + -0.037092246, + -0.004213067, + -0.0043531223, + 0.014443622, + -0.009142292, + -0.016390279, + 0.0027104884, + -0.05104575, + 0.025162615, + 0.033354487, + 0.020814797, + 0.062400658, + -0.0209473, + -0.04090357, + -0.023150489, + -0.008868237, + 0.015726084, + -0.0046364097, + -0.008184774, + 0.03815834, + 0.008516634, + -0.04098267, + -0.019736545, + 0.019957, + 0.029261561, + 0.014659777, + 0.0041951793, + 0.030671598, + 0.018239876, + 0.019828165, + 0.0026203082, + -0.02294611, + -0.03996978, + -0.03285827, + -0.009650208, + -0.017242378, + -0.004703995, + 0.07286751, + 0.029116798, + -0.027857397, + 0.030920282, + 0.0037837361, + -0.052302398, + 0.020461159, + -0.047329154, + -0.055813536, + 0.028673062, + -0.016661787, + -0.015341313, + 0.01785395, + -0.030873107, + 0.000258822, + -0.043958418, + 0.06462605, + 0.021805327, + -0.03664801, + 0.006497604, + -0.00015384165, + 0.013397299, + 0.018964294, + 0.02691174, + -0.02099989, + 0.026059551, + 0.039206848, + 0.010644643, + 0.0123791825, + 0.018565606, + -0.009426554, + 0.03002969, + 0.008392485, + -0.029192142, + -0.046032168, + -0.01656549, + 0.05159966, + -0.00035033934, + 0.019269029, + -0.026622968, + 0.0146966465, + -0.019561267, + 0.018245827, + -0.0013176868, + -0.01879243, + -0.0005474933, + 0.053611, + 0.06559852, + 0.0057520433, + 0.054350384, + -0.0020040865, + -0.013008375, + 0.045290902, + 0.006140796, + 0.024997002, + -0.00699519, + -0.009582075, + -0.013068742, + 0.019752562, + -0.020384867, + -0.012940794, + -0.0014843711, + 0.017962895, + 0.005556872, + -0.014657599, + -0.000043592292, + -0.023226345, + -0.01678709, + 0.0016942425, + -0.032125194, + -0.035317283, + 0.024794945, + -0.009424485, + 0.01486044, + 0.029850008, + 0.0753898, + 0.12631138, + 0.026418919, + -0.0036777582, + 0.010446333, + 0.0439081, + 0.0076584048, + -0.00900174, + 0.005920233, + -0.034652874, + -0.035714343, + 0.009124741, + 0.016969258, + -0.021867806, + -0.008044681, + 0.02384315, + -0.027757391, + 0.03639061, + 0.06549333, + 0.006142568, + 0.0069023883, + -0.012985581, + 0.024050307, + -0.017894344, + 0.013254618, + -0.0113636125, + 0.016258996, + -0.017367588, + 0.014721137, + 0.05368555, + -0.0021169148, + -0.021865748, + 0.04938443, + -0.026108522, + 0.041917663, + 0.013296067, + -0.017839301, + -0.04361569, + -0.012857174, + 0.0037121135, + 0.021904465, + -0.030628268, + -0.026576385, + -0.023466658, + 0.0011108342, + -0.016447578, + -0.014082683, + 0.027902713, + 0.010063626, + -0.038859334, + -0.014943547, + 0.021767579, + -0.020269709, + -0.028326584, + 0.0121445535, + 0.046655707, + -0.028418303, + -0.038227666, + -0.0041174656, + 0.0074228365, + 0.06320558, + -0.053724296, + 0.0132557405, + -0.009352387, + 0.047760453, + -0.029629279, + -0.017990856, + 0.024349233, + 0.00028578055, + -0.027793413, + 0.0063304785, + -0.0152116185, + 0.04611617, + -0.03323407, + -0.026243014, + 0.017901106, + -0.015290582, + 0.03590202, + 0.0053044567, + -0.004038325, + 0.004549081, + 0.05420171, + 0.0064602997, + 0.010330581, + 0.027606687, + -0.045506284, + 0.007835766, + 0.0065357834, + -0.06745616, + 0.005720955, + 0.03617961, + -0.025538316, + -0.042247564, + 0.018824162, + 0.023878276, + -0.004554256, + -0.036615413, + 0.023643173, + 0.0043616323, + 0.034737688, + -0.010855094, + -0.014796952, + -0.01849804, + -0.009387487, + 0.035609663, + 0.006351628, + -0.016486589, + -0.024856253, + 0.021513341, + -0.0076179, + 0.023719791, + -0.002984908, + -0.018076386, + 0.031148532, + -0.022782503, + 0.034279708, + 0.01898459, + 0.0058996603, + -0.05744395, + 0.009789155, + -0.035006523, + 0.039743777, + -0.008110541, + -0.0013241741, + 0.02553215, + -0.013267469, + -0.008391806, + -0.008371098, + -0.0026579099, + -0.03481102, + -0.040920876, + 0.018395728, + -0.046752725, + 0.04504781, + -0.00964481, + 0.013644769, + -0.039318558, + 0.008860156, + -0.019083861, + 0.04995037, + -0.013340083, + -0.018134972, + -0.00061510457, + -0.0010449832, + -0.008151584, + -0.019409627, + 0.019837735, + -0.0023270105, + -0.0130697945, + -0.03656314, + -0.002935187, + -0.021359736, + 0.038591206, + -0.01848877, + 0.031350613, + -0.062355846, + -0.009680388, + 0.040191103, + 0.04611518, + 0.016380435, + 0.03965159, + 0.03583144, + 0.045050677, + -0.015502343, + -0.006264558, + -0.0045003896, + -0.023666149, + -0.002609737, + -0.028088132, + 0.030387333, + 0.0075808335, + -0.053215895, + -0.017310234, + -0.02215732, + 0.05416475, + 0.024349486, + -0.029752845, + -0.037809394, + -0.023498412, + 0.071649745, + 0.009219032, + -0.019775221, + 0.007017144, + -0.0121129, + -0.00076495466, + -0.015328058, + 0.0303632, + 0.0049506775, + -0.015982725, + 0.030243304, + -0.036925223, + -0.027846375, + 0.03168379, + 0.030612845, + -0.037602045, + -0.020699631, + 0.00747565, + -0.0034410865, + -0.022769053, + 0.05306979, + 0.00451073, + 0.01513704, + 0.026907785, + 0.018480392, + -0.032368682, + 0.05338973, + 0.007102145, + -0.0024191206, + -0.0049164114, + 0.040670622, + 0.008154325, + 0.0046235123, + -0.053545598, + 0.029762894, + -0.0107721975, + 0.0024076146, + 0.025138626, + 0.0143650565, + -0.0023612306, + -0.05005035, + -0.028689492, + -0.043771528, + 0.018926885, + -0.033445098, + -0.012665772, + 0.0066447277, + -0.0019973472, + 0.017692404, + -0.032176174, + -0.03024897, + -0.029156476, + -0.004490404, + -0.1960561, + 0.024146989, + -0.009637343, + -0.012408774, + -0.017044682, + -0.010797171, + -0.019460207, + -0.008816897, + 0.016552463, + 0.00012896834, + -0.039890796, + 0.03164081, + -0.000023341576, + -0.041419957, + 0.047484793, + -0.0023426446, + -0.0074573387, + -0.003948018, + 0.0043869275, + 0.038115777, + 0.0171472, + -0.0056363908, + 0.024983251, + 0.011105519, + 0.061300594, + -0.014497506, + -0.020110305, + 0.002883152, + -0.029639177, + -0.022415644, + -0.012910777, + -0.060041122, + 0.00031247406, + 0.0100129135, + -0.013265406, + 0.023671923, + -0.003982369, + -0.0035333834, + 0.010600811, + 0.0076247137, + -0.017323783, + 0.0074338224, + -0.057569854, + 0.00038022033, + -0.046195455, + 0.021421483, + -0.03997735, + -0.04298212, + -0.0364845, + -0.054905728, + 0.0120653035, + -0.017789435, + 0.00086299446, + 0.024899365, + -0.021412792, + -0.014454226, + -0.009455355, + 0.014206953, + -0.012402384, + 0.031994667, + -0.025968736, + -0.016902048, + -0.0049112323, + -0.07048592, + -0.06307103, + 0.00075792347, + -0.058533836, + 0.015259777, + 0.026521623, + 0.016108816, + -0.00711015, + -0.031908505, + 0.04466164, + -0.01413894, + 0.01937052, + -0.005547107, + 0.018855283, + -0.02239894, + -0.03723086, + 0.013235031, + -0.025203003, + -0.032688282, + -0.025981527, + -0.015634263, + -0.020604322, + -0.002199724, + -0.012094437, + 0.0068054, + -0.075338244, + 0.0040479507, + -0.03933961, + -0.027105406, + -0.042796265, + 0.017378345, + -0.024187589, + -0.0028294683, + -0.012857054, + -0.038474552, + -0.020353988, + -0.0025159342, + -0.0212841, + -0.037514467, + 0.005742455, + -0.02798155, + 0.0006449985, + 0.010571165, + -0.00017978619, + -0.014382956, + 0.007303296, + -0.0008017385, + -0.03510655, + -0.034571495, + 0.005342018, + 0.023099642, + 0.040355343, + 0.02286921, + -0.0020495602, + -0.0018261133, + -0.006594679, + 0.038854826, + -0.04333866, + 0.0053720158, + 0.012483276, + -0.041920915, + -0.01041458, + -0.011107212, + 0.013396137, + 0.014620684, + -0.014068858, + -0.04063972, + 0.034610633, + -0.027733691, + 0.030721264, + 0.0051416727, + 0.0094504375, + -0.0024583114, + -0.019251036, + 0.010718494, + -0.050616268, + -0.031487107, + -0.017100725, + 0.017844666, + 0.010000536, + -0.045476876, + -0.016781637, + 0.005206351, + 0.047284976, + 0.0016722627, + -0.036074445, + -0.016852066, + 0.032321677, + 0.03983189, + 0.0019025963, + 0.012941271, + -0.03413644, + -0.013247006, + -0.0061837872, + 0.015019821, + 0.048210006, + 0.026933815, + -0.00077703776, + 0.013791499, + -0.02646392, + -0.014547354, + -0.00868276, + 0.035840802, + 0.0034411307, + -0.0012810894, + -0.009177923, + -0.056963496, + -0.002048459, + 0.036016464, + -0.035862643, + 0.021666855, + 0.059390515, + -0.006370152, + 0.015943334, + 0.048013832, + 0.042681422, + 0.00040927486, + 0.039009772, + 0.0070564505, + 0.03441962, + 0.027193261, + -0.03884982, + -0.02233137, + -0.0036596511, + -0.0017711738, + 0.010646948, + 0.020827211, + -0.018761707, + 0.022612717, + 0.006611469, + -0.019649904, + 0.06394118, + 0.05417135, + 0.012467468, + 0.0119202575, + 0.012355179, + 0.0039505903, + -0.02180499, + -0.03322551, + 0.012269383, + -0.03572913, + -0.0017401448, + 0.031165797, + -0.017695313, + 0.000029418436, + -0.014024077, + 0.007819024, + 0.02185209, + -0.031574946, + 0.01563942, + 0.028202578, + -0.0033113288, + 0.03842827, + -0.0122155445, + -0.018303305, + -0.016181288, + -0.0018545085, + -0.049622457, + -0.010243135, + -0.04628147, + 0.003903813, + -0.012328853, + 0.010740411, + -0.024007523, + 0.025895286, + 0.018677197, + 0.00028271772, + -0.02156194, + -0.005477536, + -0.005697136, + 0.026870951, + 0.008394781, + -0.041690763, + 0.03434307, + 0.035854198, + 0.021396274, + -0.004842373, + -0.011272844, + 0.02639725, + -0.033030454, + 0.016369203 + ], + "index": 0, + "object": "" + } + ], + "model": "bge_m3_embed", + "object": "list", + "provider": "", + "usage": { + "prompt_tokens": 0, + "total_tokens": 0 + } +} diff --git a/tests/contract/testdata/golden/kimi/models.golden.json b/tests/contract/testdata/golden/kimi/models.golden.json new file mode 100644 index 00000000..2ebd0094 --- /dev/null +++ b/tests/contract/testdata/golden/kimi/models.golden.json @@ -0,0 +1,11 @@ +{ + "data": [ + { + "created": 0, + "id": "kimi-for-coding", + "object": "model", + "owned_by": "" + } + ], + "object": "list" +} diff --git a/tests/contract/testdata/kimi/chat_completion.json b/tests/contract/testdata/kimi/chat_completion.json new file mode 100644 index 00000000..235f0cf3 --- /dev/null +++ b/tests/contract/testdata/kimi/chat_completion.json @@ -0,0 +1,22 @@ +{ + "id": "chatcmpl-Ke2LzM9U97NUbz9QkHonLfXT", + "object": "chat.completion", + "created": 1783125602, + "model": "kimi-for-coding", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, World!", + "reasoning_content": "The user wants me to say \"Hello, World!\" and nothing else. I should respond with exactly that, no extra text." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 18, + "completion_tokens": 32, + "total_tokens": 50 + } +} diff --git a/tests/contract/testdata/kimi/chat_completion_stream.txt b/tests/contract/testdata/kimi/chat_completion_stream.txt new file mode 100644 index 00000000..c1305dd7 --- /dev/null +++ b/tests/contract/testdata/kimi/chat_completion_stream.txt @@ -0,0 +1,106 @@ +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"We"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" need"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" respond"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" to"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" user"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"."},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" The"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" user"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" said"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" \""},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"Say"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" '"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"Hello"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":","},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" World"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"!'"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" and"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" nothing"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" else"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":".\""},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" So"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" we"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" must"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" output"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" exactly"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":":"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" Hello"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":","},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" World"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"!"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" ("},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"and"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" no"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" extra"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" text"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":")."},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" No"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" extra"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" punctuation"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"."},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" The"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" system"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" says"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" we"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" need"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" think"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" step"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" by"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":" step"},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{"reasoning_content":"."},"finish_reason":null}]} + +data:{"id":"chatcmpl-z5R135P3K7dLAfR674T7Aqox","object":"chat.completion.chunk","created":1783125604,"model":"kimi-for-coding","choices":[{"index":0,"delta":{},"finish_reason":"length","usage":{"prompt_tokens":18,"completion_tokens":50,"total_tokens":68,"cached_tokens":18,"prompt_tokens_details":{"cached_tokens":18}}}]} + +data: [DONE] + diff --git a/tests/contract/testdata/kimi/chat_with_params.json b/tests/contract/testdata/kimi/chat_with_params.json new file mode 100644 index 00000000..235f0cf3 --- /dev/null +++ b/tests/contract/testdata/kimi/chat_with_params.json @@ -0,0 +1,22 @@ +{ + "id": "chatcmpl-Ke2LzM9U97NUbz9QkHonLfXT", + "object": "chat.completion", + "created": 1783125602, + "model": "kimi-for-coding", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, World!", + "reasoning_content": "The user wants me to say \"Hello, World!\" and nothing else. I should respond with exactly that, no extra text." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 18, + "completion_tokens": 32, + "total_tokens": 50 + } +} diff --git a/tests/contract/testdata/kimi/chat_with_tools.json b/tests/contract/testdata/kimi/chat_with_tools.json new file mode 100644 index 00000000..235f0cf3 --- /dev/null +++ b/tests/contract/testdata/kimi/chat_with_tools.json @@ -0,0 +1,22 @@ +{ + "id": "chatcmpl-Ke2LzM9U97NUbz9QkHonLfXT", + "object": "chat.completion", + "created": 1783125602, + "model": "kimi-for-coding", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, World!", + "reasoning_content": "The user wants me to say \"Hello, World!\" and nothing else. I should respond with exactly that, no extra text." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 18, + "completion_tokens": 32, + "total_tokens": 50 + } +} diff --git a/tests/contract/testdata/kimi/embeddings.json b/tests/contract/testdata/kimi/embeddings.json new file mode 100644 index 00000000..cb0ef94e --- /dev/null +++ b/tests/contract/testdata/kimi/embeddings.json @@ -0,0 +1,1035 @@ +{ + "object": "list", + "model": "bge_m3_embed", + "data": [ + { + "index": 0, + "embedding": [ + -0.040394027, + 0.037034962, + -0.028974503, + 0.016117238, + -0.03569159, + -0.040737584, + -0.055054516, + -0.04086758, + 0.0032383334, + 0.0019567884, + -0.006565444, + 0.011655461, + 0.021531276, + -0.01346186, + 0.030359978, + 0.0038854934, + 0.020515833, + -0.023150796, + -0.021936541, + -0.031283665, + -0.05100716, + -0.017397156, + 0.012335982, + -0.014076596, + 0.015406209, + 0.04133278, + -0.03686478, + -0.0005830805, + 0.00033307978, + -0.060860433, + 0.037107136, + 0.08012106, + -0.017788202, + -0.049833413, + -0.034198042, + -0.0358803, + 0.0046373233, + 0.00058047334, + -0.06894698, + 0.010874012, + 0.015922088, + 0.022586372, + 0.023137117, + -0.058908414, + 0.0022468942, + -0.04434964, + -0.040362462, + -0.0024729704, + 0.0031872925, + -0.06471843, + 0.008993335, + 0.031874534, + 0.03772882, + -0.0012027833, + 0.0034979703, + 0.022246087, + -0.016932389, + -0.023046192, + -0.062191688, + 0.002164518, + -0.02052272, + -0.0096899, + -0.0035016686, + -0.007989535, + 0.0029984, + 0.15505162, + 0.030606348, + -0.01320112, + -0.004796359, + -0.015057775, + -0.018100424, + -0.012224227, + -0.0008975755, + 0.022377588, + -0.065736115, + 0.04693246, + -0.015008559, + -0.011487965, + 0.013654111, + 0.036173884, + 0.07965084, + -0.004306148, + -0.00014813247, + 0.017645223, + -0.01340765, + 0.047996882, + 0.014968728, + 0.03673915, + -0.0043187095, + -0.01352434, + -0.037223175, + -0.023028309, + 0.027777342, + -0.051526498, + -0.028421639, + 0.016847018, + -0.005437544, + 0.0197481, + 0.036299746, + 0.033107065, + 0.048874762, + 0.018898169, + 0.009175512, + -0.013093514, + 0.0026107794, + 0.0040095476, + 0.026737425, + 0.026981097, + 0.022860466, + 0.013222615, + 0.048679523, + 0.027506452, + 0.020128995, + 0.022082394, + 0.013256789, + -0.008234297, + 0.002783527, + -0.016664824, + -0.0038981326, + -0.022203082, + -0.007607345, + 0.026623992, + 0.023318416, + 0.00966249, + 0.028936656, + -0.049664255, + 0.008967275, + 0.035097916, + -0.017560534, + -0.026824968, + 0.06391159, + 0.007055419, + -0.03429222, + 0.0045853015, + -0.0061818073, + -0.0306008, + -0.002607577, + -0.025933748, + -0.013353954, + -0.028442798, + 0.022517923, + 0.06849652, + -0.021376835, + -0.015005817, + -0.013669858, + -0.05249921, + -0.028522637, + 0.01398906, + 0.018606065, + -0.04136693, + -0.004050598, + -0.021492261, + 0.033539843, + 0.046907168, + 0.032511596, + 0.0032306416, + -0.04286484, + 0.032091957, + -0.005386527, + -0.014614995, + 0.01387069, + -0.005585015, + -0.040886365, + -0.013802158, + -0.00236522, + 0.004401568, + 0.036417205, + 0.024872579, + 0.0037468828, + 0.0037328983, + -0.012898164, + -0.009925401, + 0.02674203, + -0.020238915, + -0.015350391, + 0.023067273, + 0.05697576, + 0.024388261, + 0.01784932, + -0.036647998, + -0.04267279, + 0.006546705, + -0.0003472204, + -0.023146978, + -0.044616953, + 0.034806017, + -0.029781025, + -0.009520008, + 0.023959247, + 0.01097549, + 0.0012738897, + -0.009331952, + 0.031509046, + -0.038806263, + 0.007656794, + 0.00803978, + 0.004022515, + -0.011737894, + -0.03014297, + -0.0009863945, + -0.03323577, + -0.004283858, + -0.0042973864, + 0.07079692, + -0.06307087, + 0.00060481514, + -0.012085598, + -0.009879596, + 0.030870881, + -0.019220049, + 0.021503003, + 0.033711407, + 0.026015703, + -0.02176325, + -0.060592245, + 0.013135934, + -0.042457186, + 0.0019644122, + -0.0045424593, + -0.017918002, + 0.012551367, + -0.016391398, + -0.0049237655, + 0.018882256, + 0.014345965, + 0.006059309, + 0.0090675345, + 0.0042600576, + 0.023404974, + 0.013590409, + 0.03715393, + -0.007626446, + 0.0032882274, + -0.028024923, + -0.0087687075, + -0.018998656, + -0.055875044, + -0.0011292216, + 0.037010778, + 0.008666615, + -0.0037658575, + -0.009951769, + -0.011571954, + -0.0071347463, + -0.042702693, + 0.053818572, + -0.009404992, + 0.0132118305, + 0.0037792143, + 0.005404681, + 0.012482218, + -0.025520014, + -0.003416452, + 0.026382035, + 0.06614409, + 0.0006530235, + -0.010513645, + -0.01025331, + -0.0051495302, + -0.024383359, + 0.023428103, + -0.060133316, + 0.013606713, + 0.052414786, + 0.027262071, + 0.009798058, + -0.02319962, + -0.014810931, + 0.018317463, + 0.00853516, + 0.015639363, + -0.06762488, + -0.0108342655, + -0.019835452, + -0.008034534, + 0.016289957, + -0.012568977, + -0.04713553, + 0.020333018, + 0.012736179, + 0.042933475, + -0.003036178, + 0.03600715, + -0.017816074, + -0.0013188384, + 0.017199706, + -0.06809641, + 0.014710697, + 0.02974192, + -0.047269225, + 0.016118124, + 0.05642268, + 0.03066391, + -0.008880648, + 0.020933181, + -0.0032616272, + -0.010334366, + -0.16271424, + 0.00333371, + 0.013572324, + -0.048586458, + 0.007300261, + -0.03250149, + -0.03231623, + 0.047128834, + -0.0014050837, + 0.02005525, + -0.026136903, + -0.0011201897, + -0.037740603, + 0.032731254, + -0.020704042, + 0.02685361, + 0.009267274, + -0.035277765, + 0.05858684, + -0.056308288, + 0.0049386825, + -0.0105937775, + 0.07067127, + -0.029943945, + 0.022592425, + 0.0021007804, + 0.03539531, + -0.043204278, + -0.053544767, + 0.002181767, + -0.032549735, + 0.0116085615, + -0.0064465236, + 0.021069653, + 0.024117503, + 0.050877508, + 0.02297322, + -0.03781731, + 0.0044279965, + 0.019999074, + 0.0044124615, + 0.015705047, + 0.0051618335, + 0.023606062, + -0.018036403, + -0.04582155, + -0.036633622, + 0.004093411, + -0.014024008, + -0.0015891875, + -0.01449673, + -0.004105874, + -0.021960003, + -0.056336224, + -0.01800506, + 0.013063503, + -0.031979274, + 0.018110882, + -0.012671481, + -0.010698948, + -0.014562474, + -0.037916604, + 0.018212005, + 0.0031197541, + -0.013994167, + 0.018465186, + 0.018296309, + -0.029881733, + 0.015910452, + -0.015059606, + 0.020420842, + 0.0023686956, + 0.0035341245, + -0.032052804, + 0.016920846, + -0.0103205945, + 0.034692068, + -0.010001457, + -0.03644773, + -0.149401, + -0.028203512, + 0.0029165556, + -0.02845039, + -0.0002733007, + -0.028531313, + -0.015708257, + -0.0033854707, + 0.03938203, + 0.07356379, + 0.30598772, + 0.03341856, + 0.03246554, + -0.03010564, + 0.073812455, + -0.032426286, + 0.0052742395, + 0.004848267, + 0.00045632044, + -0.021812243, + -0.018367466, + -0.0317587, + 0.006158586, + 0.032816086, + 0.018276557, + 0.051287726, + -0.060049508, + -0.015531666, + 0.048203498, + 0.025995292, + 0.008011193, + 0.017820574, + 0.0046980176, + -0.023195772, + -0.020886628, + -0.012003315, + -0.0049633808, + 0.030865058, + 0.0070968904, + 0.021434767, + 0.007991135, + 0.0045969253, + 0.022174727, + -0.0031074013, + -0.040163044, + 0.0071089347, + 0.006755876, + -0.039214622, + -0.016654883, + 0.022388421, + -0.030040137, + -0.021064214, + 0.03000585, + 0.019120656, + 0.0416361, + 0.02542441, + 0.024422735, + -0.02592873, + -0.037245583, + 0.029054156, + -0.037092246, + -0.004213067, + -0.0043531223, + 0.014443622, + -0.009142292, + -0.016390279, + 0.0027104884, + -0.05104575, + 0.025162615, + 0.033354487, + 0.020814797, + 0.062400658, + -0.0209473, + -0.04090357, + -0.023150489, + -0.008868237, + 0.015726084, + -0.0046364097, + -0.008184774, + 0.03815834, + 0.008516634, + -0.04098267, + -0.019736545, + 0.019957, + 0.029261561, + 0.014659777, + 0.0041951793, + 0.030671598, + 0.018239876, + 0.019828165, + 0.0026203082, + -0.02294611, + -0.03996978, + -0.03285827, + -0.009650208, + -0.017242378, + -0.004703995, + 0.07286751, + 0.029116798, + -0.027857397, + 0.030920282, + 0.0037837361, + -0.052302398, + 0.020461159, + -0.047329154, + -0.055813536, + 0.028673062, + -0.016661787, + -0.015341313, + 0.01785395, + -0.030873107, + 0.000258822, + -0.043958418, + 0.06462605, + 0.021805327, + -0.03664801, + 0.006497604, + -0.00015384165, + 0.013397299, + 0.018964294, + 0.02691174, + -0.02099989, + 0.026059551, + 0.039206848, + 0.010644643, + 0.0123791825, + 0.018565606, + -0.009426554, + 0.03002969, + 0.008392485, + -0.029192142, + -0.046032168, + -0.01656549, + 0.05159966, + -0.00035033934, + 0.019269029, + -0.026622968, + 0.0146966465, + -0.019561267, + 0.018245827, + -0.0013176868, + -0.01879243, + -0.0005474933, + 0.053611, + 0.06559852, + 0.0057520433, + 0.054350384, + -0.0020040865, + -0.013008375, + 0.045290902, + 0.006140796, + 0.024997002, + -0.00699519, + -0.009582075, + -0.013068742, + 0.019752562, + -0.020384867, + -0.012940794, + -0.0014843711, + 0.017962895, + 0.005556872, + -0.014657599, + -4.3592292e-05, + -0.023226345, + -0.01678709, + 0.0016942425, + -0.032125194, + -0.035317283, + 0.024794945, + -0.009424485, + 0.01486044, + 0.029850008, + 0.0753898, + 0.12631138, + 0.026418919, + -0.0036777582, + 0.010446333, + 0.0439081, + 0.0076584048, + -0.00900174, + 0.005920233, + -0.034652874, + -0.035714343, + 0.009124741, + 0.016969258, + -0.021867806, + -0.008044681, + 0.02384315, + -0.027757391, + 0.03639061, + 0.06549333, + 0.006142568, + 0.0069023883, + -0.012985581, + 0.024050307, + -0.017894344, + 0.013254618, + -0.0113636125, + 0.016258996, + -0.017367588, + 0.014721137, + 0.05368555, + -0.0021169148, + -0.021865748, + 0.04938443, + -0.026108522, + 0.041917663, + 0.013296067, + -0.017839301, + -0.04361569, + -0.012857174, + 0.0037121135, + 0.021904465, + -0.030628268, + -0.026576385, + -0.023466658, + 0.0011108342, + -0.016447578, + -0.014082683, + 0.027902713, + 0.010063626, + -0.038859334, + -0.014943547, + 0.021767579, + -0.020269709, + -0.028326584, + 0.0121445535, + 0.046655707, + -0.028418303, + -0.038227666, + -0.0041174656, + 0.0074228365, + 0.06320558, + -0.053724296, + 0.0132557405, + -0.009352387, + 0.047760453, + -0.029629279, + -0.017990856, + 0.024349233, + 0.00028578055, + -0.027793413, + 0.0063304785, + -0.0152116185, + 0.04611617, + -0.03323407, + -0.026243014, + 0.017901106, + -0.015290582, + 0.03590202, + 0.0053044567, + -0.004038325, + 0.004549081, + 0.05420171, + 0.0064602997, + 0.010330581, + 0.027606687, + -0.045506284, + 0.007835766, + 0.0065357834, + -0.06745616, + 0.005720955, + 0.03617961, + -0.025538316, + -0.042247564, + 0.018824162, + 0.023878276, + -0.004554256, + -0.036615413, + 0.023643173, + 0.0043616323, + 0.034737688, + -0.010855094, + -0.014796952, + -0.01849804, + -0.009387487, + 0.035609663, + 0.006351628, + -0.016486589, + -0.024856253, + 0.021513341, + -0.0076179, + 0.023719791, + -0.002984908, + -0.018076386, + 0.031148532, + -0.022782503, + 0.034279708, + 0.01898459, + 0.0058996603, + -0.05744395, + 0.009789155, + -0.035006523, + 0.039743777, + -0.008110541, + -0.0013241741, + 0.02553215, + -0.013267469, + -0.008391806, + -0.008371098, + -0.0026579099, + -0.03481102, + -0.040920876, + 0.018395728, + -0.046752725, + 0.04504781, + -0.00964481, + 0.013644769, + -0.039318558, + 0.008860156, + -0.019083861, + 0.04995037, + -0.013340083, + -0.018134972, + -0.00061510457, + -0.0010449832, + -0.008151584, + -0.019409627, + 0.019837735, + -0.0023270105, + -0.0130697945, + -0.03656314, + -0.002935187, + -0.021359736, + 0.038591206, + -0.01848877, + 0.031350613, + -0.062355846, + -0.009680388, + 0.040191103, + 0.04611518, + 0.016380435, + 0.03965159, + 0.03583144, + 0.045050677, + -0.015502343, + -0.006264558, + -0.0045003896, + -0.023666149, + -0.002609737, + -0.028088132, + 0.030387333, + 0.0075808335, + -0.053215895, + -0.017310234, + -0.02215732, + 0.05416475, + 0.024349486, + -0.029752845, + -0.037809394, + -0.023498412, + 0.071649745, + 0.009219032, + -0.019775221, + 0.007017144, + -0.0121129, + -0.00076495466, + -0.015328058, + 0.0303632, + 0.0049506775, + -0.015982725, + 0.030243304, + -0.036925223, + -0.027846375, + 0.03168379, + 0.030612845, + -0.037602045, + -0.020699631, + 0.00747565, + -0.0034410865, + -0.022769053, + 0.05306979, + 0.00451073, + 0.01513704, + 0.026907785, + 0.018480392, + -0.032368682, + 0.05338973, + 0.007102145, + -0.0024191206, + -0.0049164114, + 0.040670622, + 0.008154325, + 0.0046235123, + -0.053545598, + 0.029762894, + -0.0107721975, + 0.0024076146, + 0.025138626, + 0.0143650565, + -0.0023612306, + -0.05005035, + -0.028689492, + -0.043771528, + 0.018926885, + -0.033445098, + -0.012665772, + 0.0066447277, + -0.0019973472, + 0.017692404, + -0.032176174, + -0.03024897, + -0.029156476, + -0.004490404, + -0.1960561, + 0.024146989, + -0.009637343, + -0.012408774, + -0.017044682, + -0.010797171, + -0.019460207, + -0.008816897, + 0.016552463, + 0.00012896834, + -0.039890796, + 0.03164081, + -2.3341576e-05, + -0.041419957, + 0.047484793, + -0.0023426446, + -0.0074573387, + -0.003948018, + 0.0043869275, + 0.038115777, + 0.0171472, + -0.0056363908, + 0.024983251, + 0.011105519, + 0.061300594, + -0.014497506, + -0.020110305, + 0.002883152, + -0.029639177, + -0.022415644, + -0.012910777, + -0.060041122, + 0.00031247406, + 0.0100129135, + -0.013265406, + 0.023671923, + -0.003982369, + -0.0035333834, + 0.010600811, + 0.0076247137, + -0.017323783, + 0.0074338224, + -0.057569854, + 0.00038022033, + -0.046195455, + 0.021421483, + -0.03997735, + -0.04298212, + -0.0364845, + -0.054905728, + 0.0120653035, + -0.017789435, + 0.00086299446, + 0.024899365, + -0.021412792, + -0.014454226, + -0.009455355, + 0.014206953, + -0.012402384, + 0.031994667, + -0.025968736, + -0.016902048, + -0.0049112323, + -0.07048592, + -0.06307103, + 0.00075792347, + -0.058533836, + 0.015259777, + 0.026521623, + 0.016108816, + -0.00711015, + -0.031908505, + 0.04466164, + -0.01413894, + 0.01937052, + -0.005547107, + 0.018855283, + -0.02239894, + -0.03723086, + 0.013235031, + -0.025203003, + -0.032688282, + -0.025981527, + -0.015634263, + -0.020604322, + -0.002199724, + -0.012094437, + 0.0068054, + -0.075338244, + 0.0040479507, + -0.03933961, + -0.027105406, + -0.042796265, + 0.017378345, + -0.024187589, + -0.0028294683, + -0.012857054, + -0.038474552, + -0.020353988, + -0.0025159342, + -0.0212841, + -0.037514467, + 0.005742455, + -0.02798155, + 0.0006449985, + 0.010571165, + -0.00017978619, + -0.014382956, + 0.007303296, + -0.0008017385, + -0.03510655, + -0.034571495, + 0.005342018, + 0.023099642, + 0.040355343, + 0.02286921, + -0.0020495602, + -0.0018261133, + -0.006594679, + 0.038854826, + -0.04333866, + 0.0053720158, + 0.012483276, + -0.041920915, + -0.01041458, + -0.011107212, + 0.013396137, + 0.014620684, + -0.014068858, + -0.04063972, + 0.034610633, + -0.027733691, + 0.030721264, + 0.0051416727, + 0.0094504375, + -0.0024583114, + -0.019251036, + 0.010718494, + -0.050616268, + -0.031487107, + -0.017100725, + 0.017844666, + 0.010000536, + -0.045476876, + -0.016781637, + 0.005206351, + 0.047284976, + 0.0016722627, + -0.036074445, + -0.016852066, + 0.032321677, + 0.03983189, + 0.0019025963, + 0.012941271, + -0.03413644, + -0.013247006, + -0.0061837872, + 0.015019821, + 0.048210006, + 0.026933815, + -0.00077703776, + 0.013791499, + -0.02646392, + -0.014547354, + -0.00868276, + 0.035840802, + 0.0034411307, + -0.0012810894, + -0.009177923, + -0.056963496, + -0.002048459, + 0.036016464, + -0.035862643, + 0.021666855, + 0.059390515, + -0.006370152, + 0.015943334, + 0.048013832, + 0.042681422, + 0.00040927486, + 0.039009772, + 0.0070564505, + 0.03441962, + 0.027193261, + -0.03884982, + -0.02233137, + -0.0036596511, + -0.0017711738, + 0.010646948, + 0.020827211, + -0.018761707, + 0.022612717, + 0.006611469, + -0.019649904, + 0.06394118, + 0.05417135, + 0.012467468, + 0.0119202575, + 0.012355179, + 0.0039505903, + -0.02180499, + -0.03322551, + 0.012269383, + -0.03572913, + -0.0017401448, + 0.031165797, + -0.017695313, + 2.9418436e-05, + -0.014024077, + 0.007819024, + 0.02185209, + -0.031574946, + 0.01563942, + 0.028202578, + -0.0033113288, + 0.03842827, + -0.0122155445, + -0.018303305, + -0.016181288, + -0.0018545085, + -0.049622457, + -0.010243135, + -0.04628147, + 0.003903813, + -0.012328853, + 0.010740411, + -0.024007523, + 0.025895286, + 0.018677197, + 0.00028271772, + -0.02156194, + -0.005477536, + -0.005697136, + 0.026870951, + 0.008394781, + -0.041690763, + 0.03434307, + 0.035854198, + 0.021396274, + -0.004842373, + -0.011272844, + 0.02639725, + -0.033030454, + 0.016369203 + ] + } + ] +} diff --git a/tests/contract/testdata/kimi/models.json b/tests/contract/testdata/kimi/models.json new file mode 100644 index 00000000..9c1896f1 --- /dev/null +++ b/tests/contract/testdata/kimi/models.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "id": "kimi-for-coding", + "created": 1761264000, + "created_at": "2025-10-24T00:00:00Z", + "object": "model", + "display_name": "K2.7 Code", + "type": "model", + "context_length": 262144, + "supports_reasoning": true, + "supports_image_in": true, + "supports_video_in": true, + "supports_thinking_type": "only" + } + ], + "object": "list", + "first_id": "kimi-for-coding", + "last_id": "kimi-for-coding", + "has_more": false +}