From c9073cfe10fb4f31388d8b0603881cab52c5e1bf Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 27 Apr 2026 23:56:46 +0200 Subject: [PATCH 1/2] feat(providers): add deepseek responses adapter --- README.md | 10 +- cmd/gomodel/docs/docs.go | 2 +- cmd/gomodel/main.go | 4 +- config/config.example.yaml | 7 +- config/config_test.go | 1 + docs/about/roadmap.mdx | 2 +- .../0001-explicit-provider-registration.md | 2 +- docs/advanced/configuration.mdx | 10 +- docs/getting-started/quickstart.mdx | 13 +- docs/guides/codex.mdx | 34 ++- docs/openapi.json | 2 +- helm/Chart.yaml | 2 +- helm/README.md | 2 +- internal/providers/config_test.go | 23 ++ internal/providers/deepseek/deepseek.go | 177 +++++++++++++ internal/providers/deepseek/deepseek_test.go | 237 ++++++++++++++++++ 16 files changed, 507 insertions(+), 21 deletions(-) create mode 100644 internal/providers/deepseek/deepseek.go create mode 100644 internal/providers/deepseek/deepseek_test.go diff --git a/README.md b/README.md index 48d2b770..db467c04 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- A fast and lightweight AI gateway written in Go, providing a unified OpenAI-compatible API for OpenAI, Anthropic, Gemini, xAI, Groq, OpenRouter, Z.ai, Azure OpenAI, Oracle, Ollama, and more. + A fast and lightweight AI gateway written in Go, providing a unified OpenAI-compatible API for OpenAI, Anthropic, Gemini, DeepSeek, xAI, Groq, OpenRouter, Z.ai, Azure OpenAI, Oracle, Ollama, and more.

@@ -51,6 +51,7 @@ docker run --rm -p 8080:8080 \ -e OPENAI_API_KEY="your-openai-key" \ -e ANTHROPIC_API_KEY="your-anthropic-key" \ -e GEMINI_API_KEY="your-gemini-key" \ + -e DEEPSEEK_API_KEY="your-deepseek-key" \ -e GROQ_API_KEY="your-groq-key" \ -e OPENROUTER_API_KEY="your-openrouter-key" \ -e ZAI_API_KEY="your-zai-key" \ @@ -90,6 +91,7 @@ Example model identifiers are illustrative and subject to change; consult provid | OpenAI | `OPENAI_API_KEY` | `gpt-5.5` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | | Google Gemini | `GEMINI_API_KEY` | `gemini-2.5-flash` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-v4-pro` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | Groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | OpenRouter | `OPENROUTER_API_KEY` | `google/gemini-2.5-flash` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Z.ai | `ZAI_API_KEY` (`ZAI_BASE_URL` optional) | `glm-5.1` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | @@ -105,7 +107,9 @@ For Z.ai's GLM Coding Plan, set `ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v Configured model lists are available for every provider with `_MODELS`, for example `OPENROUTER_MODELS=openai/gpt-oss-120b,anthropic/claude-sonnet-4` or -`ORACLE_MODELS=openai.gpt-oss-120b,xai.grok-3`. By default, +`ORACLE_MODELS=openai.gpt-oss-120b,xai.grok-3`. DeepSeek defaults to +`https://api.deepseek.com`; set `DEEPSEEK_BASE_URL` only when using a compatible +proxy or alternate DeepSeek endpoint. By default, `CONFIGURED_PROVIDER_MODELS_MODE=fallback` uses those lists only when upstream `/models` is unavailable or empty. Set `CONFIGURED_PROVIDER_MODELS_MODE=allowlist` to expose only configured models for providers that define a list, skipping @@ -288,7 +292,7 @@ See [DEVELOPMENT.md](docs/DEVELOPMENT.md) for testing, linting, and pre-commit s ### Must Have - [ ] Intelligent routing -- [ ] Broader provider support: Oracle model configuration via environment variables, plus Cohere, Command A, Operational, and DeepSeek V3 +- [ ] Broader provider support: Cohere, Command A, and Operational - [ ] Budget management with limits per `user_path` and/or API key - [ ] Editable model pricing for accurate cost tracking and budgeting - [ ] Full support for the OpenAI `/responses` and `/conversations` lifecycle diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index d4e2e3fa..8f2d34a9 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -4355,7 +4355,7 @@ var SwaggerInfo = &swag.Spec{ BasePath: "/", Schemes: []string{"http"}, Title: "GoModel API", - Description: "High-performance AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, Z.ai, xAI, Oracle, Ollama). Drop-in OpenAI-compatible API.", + Description: "High-performance AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, DeepSeek, Z.ai, xAI, MiniMax, Oracle, Ollama). Drop-in OpenAI-compatible API.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/cmd/gomodel/main.go b/cmd/gomodel/main.go index cc911cc8..18a9333e 100644 --- a/cmd/gomodel/main.go +++ b/cmd/gomodel/main.go @@ -18,6 +18,7 @@ import ( "gomodel/internal/providers" "gomodel/internal/providers/anthropic" "gomodel/internal/providers/azure" + "gomodel/internal/providers/deepseek" "gomodel/internal/providers/gemini" "gomodel/internal/providers/groq" "gomodel/internal/providers/minimax" @@ -73,7 +74,7 @@ func startApplication(application lifecycleApp, addr string) error { // @title GoModel API // @version 1.0 -// @description High-performance AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, Z.ai, xAI, MiniMax, Oracle, Ollama). Drop-in OpenAI-compatible API. +// @description High-performance AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, DeepSeek, Z.ai, xAI, MiniMax, Oracle, Ollama). Drop-in OpenAI-compatible API. // @BasePath / // @schemes http // @securityDefinitions.apikey BearerAuth @@ -119,6 +120,7 @@ func main() { factory.Add(azure.Registration) factory.Add(oracle.Registration) factory.Add(anthropic.Registration) + factory.Add(deepseek.Registration) factory.Add(gemini.Registration) factory.Add(groq.Registration) factory.Add(minimax.Registration) diff --git a/config/config.example.yaml b/config/config.example.yaml index bb3ff27c..359bf7d2 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -264,10 +264,11 @@ providers: # - openai.gpt-oss-120b # - xai.grok-3 - # Example: DeepSeek (OpenAI-compatible) + # Example: DeepSeek. GoModel translates /v1/responses requests to DeepSeek + # chat completions because DeepSeek does not expose a native Responses API. # deepseek: - # type: "openai" - # base_url: "https://api.deepseek.com/v1" + # type: "deepseek" + # base_url: "https://api.deepseek.com" # api_key: "${DEEPSEEK_API_KEY}" # Example: local Ollama server with explicit per-model metadata. diff --git a/config/config_test.go b/config/config_test.go index dfaf772f..1b8a942f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -18,6 +18,7 @@ func clearProviderEnvVars(t *testing.T) { "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODELS", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODELS", "GEMINI_API_KEY", "GEMINI_BASE_URL", "GEMINI_MODELS", + "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL", "DEEPSEEK_MODELS", "XAI_API_KEY", "XAI_BASE_URL", "XAI_MODELS", "GROQ_API_KEY", "GROQ_BASE_URL", "GROQ_MODELS", "OPENROUTER_API_KEY", "OPENROUTER_BASE_URL", "OPENROUTER_MODELS", "OPENROUTER_SITE_URL", "OPENROUTER_APP_NAME", diff --git a/docs/about/roadmap.mdx b/docs/about/roadmap.mdx index b279c59a..6247660f 100644 --- a/docs/about/roadmap.mdx +++ b/docs/about/roadmap.mdx @@ -7,7 +7,7 @@ icon: "list-todo" ### Must Have - [ ] Intelligent routing -- [ ] Broader provider support: Oracle model configuration via environment variables, plus Cohere, Command A, Operational, and DeepSeek V3 +- [ ] Broader provider support: Cohere, Command A, and Operational - [ ] Budget management with limits per `user_path` and/or API key - [ ] Editable model pricing for accurate cost tracking and budgeting - [ ] Full support for the OpenAI `/responses` and `/conversations` lifecycle diff --git a/docs/adr/0001-explicit-provider-registration.md b/docs/adr/0001-explicit-provider-registration.md index 80776bea..2fc5a3d8 100644 --- a/docs/adr/0001-explicit-provider-registration.md +++ b/docs/adr/0001-explicit-provider-registration.md @@ -2,7 +2,7 @@ ## Context -GoModel supports multiple LLM providers, including OpenAI, Anthropic, Gemini, xAI, Groq, OpenRouter, Z.ai, Azure OpenAI, Oracle, Ollama, and custom OpenAI-compatible endpoints. Each provider must be registered with the factory before use. +GoModel supports multiple LLM providers, including OpenAI, Anthropic, Gemini, DeepSeek, xAI, Groq, OpenRouter, Z.ai, Azure OpenAI, Oracle, Ollama, and custom OpenAI-compatible endpoints. Each provider must be registered with the factory before use. ## Decision diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index fb03a177..76edd057 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -181,6 +181,7 @@ Set these to automatically register providers. No YAML configuration required. | `OPENAI_API_KEY` | OpenAI | | `ANTHROPIC_API_KEY` | Anthropic | | `GEMINI_API_KEY` | Google Gemini | +| `DEEPSEEK_API_KEY` | DeepSeek | | `OPENROUTER_API_KEY` | OpenRouter | | `ZAI_API_KEY` | Z.ai | | `XAI_API_KEY` | xAI (Grok) | @@ -190,7 +191,7 @@ Set these to automatically register providers. No YAML configuration required. | `OLLAMA_BASE_URL` | Ollama (no API key needed) | | `VLLM_BASE_URL` | vLLM (no API key needed unless upstream requires) | -Most providers can use a custom base URL via `_BASE_URL` (for example `OPENAI_BASE_URL`). OpenRouter defaults to `https://openrouter.ai/api/v1` and can be overridden with `OPENROUTER_BASE_URL`. Z.ai defaults to `https://api.z.ai/api/paas/v4`; set `ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4` for the GLM Coding Plan endpoint. vLLM defaults to `http://localhost:8000/v1` when `VLLM_API_KEY` is set, but keyless deployments should set `VLLM_BASE_URL` explicitly to register the provider. Azure uses `AZURE_BASE_URL` for its deployment base URL and accepts an optional `AZURE_API_VERSION` override; otherwise it defaults to `2024-10-21`. Oracle requires `ORACLE_BASE_URL` because its OpenAI-compatible endpoint is region-specific. +Most providers can use a custom base URL via `_BASE_URL` (for example `OPENAI_BASE_URL`). DeepSeek defaults to `https://api.deepseek.com`; set `DEEPSEEK_BASE_URL` only for a compatible proxy or alternate DeepSeek endpoint. OpenRouter defaults to `https://openrouter.ai/api/v1` and can be overridden with `OPENROUTER_BASE_URL`. Z.ai defaults to `https://api.z.ai/api/paas/v4`; set `ZAI_BASE_URL=https://api.z.ai/api/coding/paas/v4` for the GLM Coding Plan endpoint. vLLM defaults to `http://localhost:8000/v1` when `VLLM_API_KEY` is set, but keyless deployments should set `VLLM_BASE_URL` explicitly to register the provider. Azure uses `AZURE_BASE_URL` for its deployment base URL and accepts an optional `AZURE_API_VERSION` override; otherwise it defaults to `2024-10-21`. Oracle requires `ORACLE_BASE_URL` because its OpenAI-compatible endpoint is region-specific. Every provider type also accepts a comma-separated configured model list via `_MODELS`, for example `OPENROUTER_MODELS`, `ORACLE_MODELS`, @@ -311,6 +312,7 @@ The simplest way to add providers. GoModel checks for well-known API key environ export OPENAI_API_KEY="sk-..." # Registers "openai" provider export ANTHROPIC_API_KEY="sk-ant-..." # Registers "anthropic" provider export GEMINI_API_KEY="..." # Registers "gemini" provider +export DEEPSEEK_API_KEY="..." # Registers "deepseek" provider export XAI_API_KEY="..." # Registers "xai" provider export GROQ_API_KEY="gsk_..." # Registers "groq" provider export OPENROUTER_API_KEY="sk-or-..." # Registers "openrouter" provider @@ -381,6 +383,12 @@ providers: - openai.gpt-oss-120b - xai.grok-3 + # Add DeepSeek. GoModel translates /v1/responses to DeepSeek chat completions. + deepseek: + type: deepseek + base_url: "https://api.deepseek.com" + api_key: "..." + # Add a vLLM OpenAI-compatible server vllm: type: vllm diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 377918de..9470866d 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -7,9 +7,9 @@ icon: "rocket" ## Run GoModel in 30 Seconds GoModel is an OpenAI-compatible AI gateway. You can connect one endpoint and -route traffic across OpenAI, Anthropic, Gemini, xAI, Groq, OpenRouter, Z.ai, -Azure OpenAI, Oracle, Ollama, and more while keeping auth, audit logs, and admin -visibility in one place. +route traffic across OpenAI, Anthropic, Gemini, DeepSeek, xAI, Groq, OpenRouter, +Z.ai, Azure OpenAI, Oracle, Ollama, and more while keeping auth, audit logs, and +admin visibility in one place. ### 1. Start GoModel @@ -26,9 +26,10 @@ docker run --rm -p 8080:8080 \ Set at least one provider credential or base URL - (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, - `GROQ_API_KEY`, `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `AZURE_API_KEY` + - `AZURE_BASE_URL`, `ORACLE_API_KEY` + `ORACLE_BASE_URL`, + (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, + `DEEPSEEK_API_KEY`, `XAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY`, + `ZAI_API_KEY`, `AZURE_API_KEY` + `AZURE_BASE_URL`, + `ORACLE_API_KEY` + `ORACLE_BASE_URL`, `OLLAMA_BASE_URL`) or GoModel will have no models to route. GoModel also works well with additional OpenAI-compatible providers out of the box. diff --git a/docs/guides/codex.mdx b/docs/guides/codex.mdx index 071dbffa..36f8c81c 100644 --- a/docs/guides/codex.mdx +++ b/docs/guides/codex.mdx @@ -9,7 +9,7 @@ Responses API. Flow: -`Codex -> GoModel -> OpenAI` +`Codex -> GoModel -> upstream model provider` ## Before you start @@ -97,6 +97,38 @@ The validated result was: ok ``` +## DeepSeek V4 + +Codex sends `POST /v1/responses`. DeepSeek exposes chat completions instead of +a native Responses API, so configure the first-class DeepSeek provider and let +GoModel translate the request. + +```yaml +providers: + deepseek: + type: deepseek + base_url: "https://api.deepseek.com" + api_key: "${DEEPSEEK_API_KEY}" +``` + +If you previously configured DeepSeek as `type: openai`, change it to +`type: deepseek` for Codex. The generic OpenAI provider forwards `/responses` +upstream, while the DeepSeek provider translates `/responses` to +`/chat/completions`. + +Then use the DeepSeek model name in Codex: + +```toml +model_provider = "gomodel" +model = "deepseek-v4-pro" + +[model_providers.gomodel] +name = "GoModel" +base_url = "http://localhost:8080/v1" +env_key = "OPENAI_API_KEY" +wire_api = "responses" +``` + ## 5. Check the traffic in GoModel Open the GoModel dashboard audit logs: diff --git a/docs/openapi.json b/docs/openapi.json index 023f0a92..4ffb4ed3 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.0", "info": { - "description": "High-performance AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, Z.ai, xAI, MiniMax, Oracle, Ollama). Drop-in OpenAI-compatible API.", + "description": "High-performance AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, DeepSeek, Z.ai, xAI, MiniMax, Oracle, Ollama). Drop-in OpenAI-compatible API.", "title": "GoModel API", "contact": {}, "version": "1.0" diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 516cd401..f63e5ed4 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: gomodel -description: High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, Z.ai, xAI) +description: High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Gemini, DeepSeek, Groq, Z.ai, xAI) type: application version: 0.1.0 appVersion: "1.0.0" diff --git a/helm/README.md b/helm/README.md index 40e6d7e2..7f2e0752 100644 --- a/helm/README.md +++ b/helm/README.md @@ -1,6 +1,6 @@ # GoModel Helm Chart -High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, Z.ai, xAI, Oracle). +High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Gemini, DeepSeek, Groq, Z.ai, xAI, Oracle). ## Prerequisites diff --git a/internal/providers/config_test.go b/internal/providers/config_test.go index c4980f8c..37f2736f 100644 --- a/internal/providers/config_test.go +++ b/internal/providers/config_test.go @@ -27,6 +27,9 @@ var testDiscoveryConfigs = map[string]DiscoveryConfig{ "gemini": { DefaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", }, + "deepseek": { + DefaultBaseURL: "https://api.deepseek.com", + }, "xai": { DefaultBaseURL: "https://api.x.ai/v1", }, @@ -402,6 +405,26 @@ func TestApplyProviderEnvVars_DiscoversOpenRouterFromAPIKey(t *testing.T) { } } +func TestApplyProviderEnvVars_DiscoversDeepSeekFromAPIKey(t *testing.T) { + t.Setenv("DEEPSEEK_API_KEY", "deepseek-key") + + got := applyProviderEnvVars(map[string]config.RawProviderConfig{}, testDiscoveryConfigs) + + p, exists := got["deepseek"] + if !exists { + t.Fatal("expected deepseek to be discovered from env var") + } + if p.APIKey != "deepseek-key" { + t.Errorf("APIKey = %q, want deepseek-key", p.APIKey) + } + if p.Type != "deepseek" { + t.Errorf("Type = %q, want deepseek", p.Type) + } + if p.BaseURL != testDiscoveryConfigs["deepseek"].DefaultBaseURL { + t.Errorf("BaseURL = %q, want %q", p.BaseURL, testDiscoveryConfigs["deepseek"].DefaultBaseURL) + } +} + func TestApplyProviderEnvVars_DiscoversZAIFromAPIKey(t *testing.T) { t.Setenv("ZAI_API_KEY", "zai-key") diff --git a/internal/providers/deepseek/deepseek.go b/internal/providers/deepseek/deepseek.go new file mode 100644 index 00000000..9af46f92 --- /dev/null +++ b/internal/providers/deepseek/deepseek.go @@ -0,0 +1,177 @@ +// Package deepseek provides DeepSeek API integration for the LLM gateway. +package deepseek + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + + "gomodel/internal/core" + "gomodel/internal/llmclient" + "gomodel/internal/providers" +) + +const defaultBaseURL = "https://api.deepseek.com" + +// Registration provides factory registration for the DeepSeek provider. +var Registration = providers.Registration{ + Type: "deepseek", + New: New, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + }, +} + +// Provider implements the core.Provider interface for DeepSeek. +type Provider struct { + client *llmclient.Client + apiKey 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} + clientCfg := llmclient.Config{ + ProviderName: "deepseek", + BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), + Retry: opts.Resilience.Retry, + Hooks: opts.Hooks, + CircuitBreaker: opts.Resilience.CircuitBreaker, + } + p.client = llmclient.New(clientCfg, p.setHeaders) + return p +} + +// 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 == nil { + httpClient = http.DefaultClient + } + p := &Provider{apiKey: apiKey} + cfg := llmclient.DefaultConfig("deepseek", providers.ResolveBaseURL(baseURL, defaultBaseURL)) + cfg.Hooks = hooks + p.client = llmclient.NewWithHTTPClient(httpClient, cfg, p.setHeaders) + return p +} + +// SetBaseURL allows configuring a custom base URL for the provider. +func (p *Provider) SetBaseURL(url string) { + p.client.SetBaseURL(url) +} + +func (p *Provider) setHeaders(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+p.apiKey) + if requestID := core.GetRequestID(req.Context()); requestID != "" { + req.Header.Set("X-Request-Id", requestID) + } +} + +// adaptChatRequest rewrites GoModel's common reasoning shape into DeepSeek's +// OpenAI-compatible chat extension. DeepSeek accepts reasoning_effort as a +// top-level string, not "reasoning": {"effort": "..."}. +func adaptChatRequest(req *core.ChatRequest) (any, error) { + if req == nil || req.Reasoning == nil || strings.TrimSpace(req.Reasoning.Effort) == "" { + return req, nil + } + + body, err := json.Marshal(req) + if err != nil { + return nil, core.NewInvalidRequestError("failed to marshal deepseek request: "+err.Error(), err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return nil, core.NewInvalidRequestError("failed to decode deepseek request payload: "+err.Error(), err) + } + + effort, _ := json.Marshal(normalizeReasoningEffort(req.Reasoning.Effort)) + raw["reasoning_effort"] = effort + delete(raw, "reasoning") + return raw, nil +} + +func normalizeReasoningEffort(effort string) string { + normalized := strings.ToLower(strings.TrimSpace(effort)) + switch normalized { + case "low", "medium": + return "high" + case "xhigh", "max": + return "max" + default: + return normalized + } +} + +// ChatCompletion sends a chat completion request to DeepSeek. +func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + if req == nil { + return nil, core.NewInvalidRequestError("chat request is required", nil) + } + body, err := adaptChatRequest(req) + if err != nil { + return nil, err + } + var resp core.ChatResponse + err = p.client.Do(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/chat/completions", + Body: body, + }, &resp) + if err != nil { + return nil, err + } + if resp.Model == "" { + resp.Model = req.Model + } + return &resp, nil +} + +// StreamChatCompletion returns a raw response body for streaming. +func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + if req == nil { + return nil, core.NewInvalidRequestError("chat request is required", nil) + } + streamReq := req.WithStreaming() + body, err := adaptChatRequest(streamReq) + if err != nil { + return nil, err + } + return p.client.DoStream(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/chat/completions", + Body: body, + }) +} + +// ListModels retrieves the list of available models from DeepSeek. +func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + var resp core.ModelsResponse + err := p.client.Do(ctx, llmclient.Request{ + Method: http.MethodGet, + Endpoint: "/models", + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +// Responses sends a Responses API request to DeepSeek using chat-completions translation. +func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return providers.ResponsesViaChat(ctx, p, req) +} + +// StreamResponses streams a Responses API request to DeepSeek using chat-completions translation. +func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + return providers.StreamResponsesViaChat(ctx, p, req, "deepseek") +} + +// Embeddings returns an error because DeepSeek does not expose an embeddings endpoint. +func (p *Provider) Embeddings(_ context.Context, _ *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return nil, core.NewInvalidRequestError("deepseek does not support embeddings", nil) +} diff --git a/internal/providers/deepseek/deepseek_test.go b/internal/providers/deepseek/deepseek_test.go new file mode 100644 index 00000000..c97dd16e --- /dev/null +++ b/internal/providers/deepseek/deepseek_test.go @@ -0,0 +1,237 @@ +package deepseek + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gomodel/internal/core" + "gomodel/internal/llmclient" +) + +func TestChatCompletion_UsesBearerAuthAndChatEndpoint(t *testing.T) { + 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(`{ + "id":"chatcmpl-deepseek", + "created":1677652288, + "model":"deepseek-v4-pro", + "choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + + resp, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "deepseek-v4-pro", + Messages: []core.Message{ + {Role: "user", Content: "hi"}, + }, + }) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if resp.Model != "deepseek-v4-pro" { + t.Fatalf("resp.Model = %q, want deepseek-v4-pro", resp.Model) + } + if gotPath != "/chat/completions" { + t.Fatalf("path = %q, want /chat/completions", gotPath) + } + if gotAuth != "Bearer deepseek-key" { + t.Fatalf("authorization = %q, want Bearer deepseek-key", gotAuth) + } +} + +func TestChatCompletion_MapsReasoningToDeepSeekReasoningEffort(t *testing.T) { + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + http.Error(w, "decode error", http.StatusBadRequest) + return + } + 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":"ok"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + + _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{ + Model: "deepseek-v4-pro", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + Reasoning: &core.Reasoning{Effort: "medium"}, + }) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if gotBody["reasoning"] != nil { + t.Fatalf("request body should not include nested reasoning, got %#v", gotBody["reasoning"]) + } + if gotBody["reasoning_effort"] != "high" { + t.Fatalf("reasoning_effort = %#v, want high", gotBody["reasoning_effort"]) + } +} + +func TestResponses_TranslatesToChatCompletions(t *testing.T) { + var gotPath string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + http.Error(w, "decode error", http.StatusBadRequest) + return + } + 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":"translated"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5} + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + maxOutputTokens := 64 + + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ + Model: "deepseek-v4-pro", + Input: "Reply with exactly ok", + MaxOutputTokens: &maxOutputTokens, + Reasoning: &core.Reasoning{Effort: "xhigh"}, + }) + if err != nil { + t.Fatalf("Responses() error = %v", err) + } + if gotPath != "/chat/completions" { + t.Fatalf("path = %q, want /chat/completions", gotPath) + } + if gotBody["max_output_tokens"] != nil { + t.Fatalf("request body should not include max_output_tokens, got %#v", gotBody["max_output_tokens"]) + } + if gotBody["max_tokens"] != float64(64) { + t.Fatalf("max_tokens = %#v, want 64", gotBody["max_tokens"]) + } + if gotBody["reasoning_effort"] != "max" { + t.Fatalf("reasoning_effort = %#v, want max", gotBody["reasoning_effort"]) + } + messages, ok := gotBody["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v, want one chat message", gotBody["messages"]) + } + message, _ := messages[0].(map[string]any) + if message["role"] != "user" || message["content"] != "Reply with exactly ok" { + t.Fatalf("message = %#v, want converted user message", message) + } + if resp.Object != "response" || resp.Status != "completed" { + t.Fatalf("response metadata = object %q status %q, want response/completed", resp.Object, resp.Status) + } + if len(resp.Output) != 1 || len(resp.Output[0].Content) != 1 || resp.Output[0].Content[0].Text != "translated" { + t.Fatalf("unexpected responses output: %+v", resp.Output) + } + if resp.Usage == nil || resp.Usage.TotalTokens != 5 { + t.Fatalf("usage = %+v, want total_tokens=5", resp.Usage) + } +} + +func TestStreamResponses_TranslatesToChatCompletions(t *testing.T) { + var gotPath string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + http.Error(w, "decode error", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"created\":1677652288,\"model\":\"deepseek-v4-pro\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + provider := NewWithHTTPClient("deepseek-key", server.URL, server.Client(), llmclient.Hooks{}) + + stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "deepseek-v4-pro", + Input: "hi", + }) + if err != nil { + t.Fatalf("StreamResponses() error = %v", err) + } + defer stream.Close() + + body, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if gotPath != "/chat/completions" { + t.Fatalf("path = %q, want /chat/completions", gotPath) + } + if gotBody["stream"] != true { + t.Fatalf("stream = %#v, want true", gotBody["stream"]) + } + raw := string(body) + if !strings.Contains(raw, "response.output_text.delta") || !strings.Contains(raw, "data: [DONE]") { + t.Fatalf("converted stream missing responses events or done marker: %s", raw) + } +} + +func TestNormalizeReasoningEffort(t *testing.T) { + tests := map[string]string{ + "low": "high", + "medium": "high", + "high": "high", + "xhigh": "max", + "max": "max", + "custom": "custom", + } + for input, expected := range tests { + t.Run(input, func(t *testing.T) { + if got := normalizeReasoningEffort(input); got != expected { + t.Fatalf("normalizeReasoningEffort(%q) = %q, want %q", input, got, expected) + } + }) + } +} + +func TestProvider_DoesNotExposeOptionalNativeInterfaces(t *testing.T) { + provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}) + + if _, ok := any(provider).(core.NativeBatchProvider); ok { + t.Fatal("deepseek provider should not implement native batch provider") + } + if _, ok := any(provider).(core.NativeFileProvider); ok { + t.Fatal("deepseek provider should not implement native file provider") + } + if _, ok := any(provider).(core.NativeResponseLifecycleProvider); ok { + t.Fatal("deepseek provider should not implement native response lifecycle provider") + } +} + +func TestEmbeddings_ReturnsUnsupported(t *testing.T) { + provider := NewWithHTTPClient("deepseek-key", "", nil, llmclient.Hooks{}) + + _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "embedding-model", Input: "hi"}) + if err == nil { + t.Fatal("expected unsupported embeddings error, got nil") + } +} From 052c6808ead340c2c51ca815ec56b6951b919ac4 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Tue, 28 Apr 2026 00:09:49 +0200 Subject: [PATCH 2/2] docs(deepseek): add provider guide with reasoning effort mapping Document DeepSeek V4's two-level reasoning_effort surface and the low/medium -> high remap so users aren't surprised by the upgrade. Cross-link from the Codex guide and add a comment on normalizeReasoningEffort pointing to the user-facing table. Co-Authored-By: Claude Opus 4.7 --- docs/docs.json | 1 + docs/guides/codex.mdx | 4 ++ docs/guides/deepseek.mdx | 67 +++++++++++++++++++++++++ internal/providers/deepseek/deepseek.go | 5 ++ 4 files changed, 77 insertions(+) create mode 100644 docs/guides/deepseek.mdx diff --git a/docs/docs.json b/docs/docs.json index c681c215..974fae70 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -86,6 +86,7 @@ "pages": [ "guides/openclaw", "guides/oracle", + "guides/deepseek", "guides/vllm", "guides/multiple-ollama", "guides/claude-code", diff --git a/docs/guides/codex.mdx b/docs/guides/codex.mdx index 36f8c81c..e79e0c3d 100644 --- a/docs/guides/codex.mdx +++ b/docs/guides/codex.mdx @@ -116,6 +116,10 @@ If you previously configured DeepSeek as `type: openai`, change it to upstream, while the DeepSeek provider translates `/responses` to `/chat/completions`. +See the [DeepSeek guide](/guides/deepseek) for the full reasoning effort +mapping table (DeepSeek V4 only accepts `high` and `max`, so GoModel maps +`low` and `medium` up to `high`). + Then use the DeepSeek model name in Codex: ```toml diff --git a/docs/guides/deepseek.mdx b/docs/guides/deepseek.mdx new file mode 100644 index 00000000..b543427d --- /dev/null +++ b/docs/guides/deepseek.mdx @@ -0,0 +1,67 @@ +--- +title: "GoModel & DeepSeek" +description: "Configure DeepSeek V4 in GoModel and understand how reasoning effort is mapped to DeepSeek's reasoning_effort field." +icon: "brain" +--- + +GoModel routes to DeepSeek through DeepSeek's chat completions API. DeepSeek +does not expose a native Responses API, so GoModel translates `/v1/responses` +requests to `/chat/completions` automatically. + +## 1. Configure DeepSeek + +Env-only is enough: + +```bash +export DEEPSEEK_API_KEY="..." +``` + +Or in `config.yaml` (not recommended): + +```yaml +providers: + deepseek: + type: deepseek + base_url: "https://api.deepseek.com" + api_key: "${DEEPSEEK_API_KEY}" +``` + + + If you previously configured DeepSeek as `type: openai`, switch it to `type: + deepseek` so GoModel translates `/v1/responses` and remaps reasoning effort. + The generic OpenAI provider does neither. + + +## 2. Reasoning effort mapping + +DeepSeek V4 reasoning models accept `reasoning_effort` as a top-level string +with two levels: `high` and `max`. GoModel accepts the standard OpenAI-style +levels and remaps them: + +| Client sends | DeepSeek receives | +| --------------- | ----------------- | +| `low` | `high` | +| `medium` | `high` | +| `high` | `high` | +| `xhigh` / `max` | `max` | +| anything else | passed through | + +`low` and `medium` are mapped up to `high` because DeepSeek's reasoning models +do not accept lower levels. If you want to avoid reasoning entirely, omit the +`reasoning` field instead of passing `low`. + +GoModel rewrites the request body so DeepSeek sees a top-level +`reasoning_effort: "..."` instead of OpenAI's nested +`"reasoning": {"effort": "..."}` shape. No client change is required. + +## Current support + +Integrated: + +- chat completions and streaming +- Responses API and streaming (translated to chat completions) +- model listing through `/models` + +Not supported by DeepSeek: + +- embeddings (returns an `invalid_request_error`) diff --git a/internal/providers/deepseek/deepseek.go b/internal/providers/deepseek/deepseek.go index 9af46f92..1b512012 100644 --- a/internal/providers/deepseek/deepseek.go +++ b/internal/providers/deepseek/deepseek.go @@ -95,6 +95,11 @@ func adaptChatRequest(req *core.ChatRequest) (any, error) { return raw, nil } +// normalizeReasoningEffort maps GoModel's OpenAI-style effort levels to the two +// levels DeepSeek V4 accepts ("high" and "max"). "low" and "medium" are mapped +// up to "high" because DeepSeek does not support lower levels; clients that +// want to disable reasoning should omit the field entirely. See +// docs/guides/deepseek.mdx for the user-facing table. func normalizeReasoningEffort(effort string) string { normalized := strings.ToLower(strings.TrimSpace(effort)) switch normalized {