From 5b507b61a4122976791a1f0c74c053f737f408a7 Mon Sep 17 00:00:00 2001 From: Marc-oss-hub <315200685+Marc-oss-hub@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:22:23 +0800 Subject: [PATCH] feat(providers): add OrcaRouter provider Add a first-class OrcaRouter provider mirroring the existing OpenRouter integration. OrcaRouter is an OpenAI-compatible AI gateway exposing ~190 upstream models under provider-scoped IDs, so the shared OpenAI-compatible transport is reused wholesale and only authentication (Bearer + client request/session IDs) is specialized. - internal/providers/orcarouter: provider, passthrough semantic enricher, and transport tests - register the provider in the default factory and credential schemas - enable passthrough for orcarouter by default - price orcarouter usage with the OpenAI-compatible token mappings - document ORCAROUTER_* env vars, YAML example, and swagger description Co-Authored-By: Claude --- .env.template | 8 +- CLAUDE.md | 2 +- cmd/gomodel/main.go | 2 +- config/config.example.yaml | 13 +- config/config.go | 1 + config/config_test.go | 4 +- config/server.go | 2 +- docs/advanced/configuration.mdx | 5 +- docs/features/passthrough-api.mdx | 4 +- internal/gateway/inference_execute.go | 2 +- internal/providers/orcarouter/orcarouter.go | 73 ++++++++++++ .../providers/orcarouter/orcarouter_test.go | 111 ++++++++++++++++++ .../orcarouter/passthrough_semantics.go | 5 + internal/server/handlers_test.go | 2 +- internal/server/passthrough_support.go | 2 +- internal/usage/cost.go | 1 + run/providers.go | 2 + run/providers_test.go | 9 +- 18 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 internal/providers/orcarouter/orcarouter.go create mode 100644 internal/providers/orcarouter/orcarouter_test.go create mode 100644 internal/providers/orcarouter/passthrough_semantics.go diff --git a/.env.template b/.env.template index 340b66ca6..267ff510b 100644 --- a/.env.template +++ b/.env.template @@ -68,7 +68,7 @@ # Allow optional /p/{provider}/v1/... passthrough aliases while keeping /p/{provider}/... canonical (default: true) # ALLOW_PASSTHROUGH_V1_ALIAS=true -# Comma-separated list of provider types enabled for /p/{provider}/... passthrough (default: openai,anthropic,openrouter,kilo,zai,vllm,llmd,deepseek) +# Comma-separated list of provider types enabled for /p/{provider}/... passthrough (default: openai,anthropic,openrouter,orcarouter,kilo,zai,vllm,llmd,deepseek) # Cohere native passthrough is opt-in; add cohere when those routes are needed. # ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,cohere,openrouter,kilo,zai,vllm,llmd,deepseek @@ -480,6 +480,12 @@ # OPENROUTER_SITE_URL=https://gomodel.enterpilot.io # OPENROUTER_APP_NAME=GoModel +# OrcaRouter (default base URL: https://api.orcarouter.ai/v1) +# ORCAROUTER_API_KEY=sk-orca-... +# ORCAROUTER_BASE_URL=https://api.orcarouter.ai/v1 +# Optional configured model list; see CONFIGURED_PROVIDER_MODELS_MODE below +# ORCAROUTER_MODELS=openai/gpt-4o-mini,anthropic/claude-sonnet-4 + # Kilo AI Gateway (default base URL: https://api.kilo.ai/api/gateway) # Model IDs use provider/model and pass through unchanged. # KILO_API_KEY=... diff --git a/CLAUDE.md b/CLAUDE.md index 5cb553b40..c2dd717c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ Full reference: `.env.template` and `config/config.yaml` - `PID_FILE` / `server.pid_file` (`data/gomodel.pid` next to a `./data` directory, otherwise the OS per-user data dir — same resolution as `SQLITE_PATH`): where the running gateway records its process id. `gomodel --reload` reads it and signals that process (SIGHUP; `kill -HUP` works too) to reload configuration without a restart, like `nginx -s reload`. The reload re-reads `.env` (exported variables still win over the file; variables removed from the file are unset) and the whole config, then rebuilds the application — so every setting reloads, not a curated subset. The replacement is built before the running one is stopped, so a broken config keeps the current one serving; the listening socket is held across generations, so no connection is refused mid-reload. `PORT` and `PID_FILE` changes still need a restart (warned about), and in-memory state — rate limit counters, session affinity pins, live log buffers — resets as it would on restart. `server.pid_file: ""` in `config.yaml` disables the pid file and `--reload` (an empty `PID_FILE` env var reads as unset and keeps the default). Not available on Windows (POSIX signals). - `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,kilo,zai,sglang,vllm,deepseek: Comma-separated list of enabled passthrough providers) + - `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,orcarouter,kilo,zai,sglang,vllm,llmd,deepseek: 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. The same flag also exposes the OpenAI-compatible WebRTC surface (via the optional `core.RealtimeCallProvider` interface — OpenAI and xAI at the shared `…/v1/realtime/{calls,client_secrets}` shape, and Azure OpenAI at its GA `/openai/v1/realtime/{calls,client_secrets}` surface with `api-key` auth and no api-version; xAI gates WebRTC calls per team, so unauthorized accounts get the upstream 403 relayed while client_secrets works. Bailian is deliberately not wired: its WebRTC is allowlist-only with a per-customer endpoint provided by sales, plus no call id in the answer; Z.ai has no WebRTC realtime): `POST /v1/realtime/calls` exchanges SDP (raw `application/sdp` offer with `?model=`, or multipart `sdp` + `session` JSON fields; the session/query model is rewritten to the resolved provider model so aliases and virtual models work) and relays the answer with a gateway-relative `Location: /v1/realtime/calls/{call_id}` header; `POST /v1/realtime/client_secrets` mints ephemeral browser credentials routed by `session.model` (falling back to the nested transcription model); and `GET /v1/realtime?call_id=…` attaches to an existing call as a sideband websocket (an in-memory per-instance call registry recalls the route for calls created through the same instance — 6h TTL, capped; otherwise pass explicit `model`+`provider` params). WebRTC media and events flow directly between client and provider, so after creating a call the gateway attaches its own best-effort sideband observer websocket to record usage per `response.done` (entries carry endpoint `/v1/realtime/calls`; skipped when usage tracking is off, and gateway-relayed sideband attaches for registry-known calls don't tap usage to avoid double counting). WebRTC signaling counts toward request-scoped rate limits, but concurrent-scope rules can't span a WebRTC call's lifetime since only signaling transits the gateway; ephemeral client secrets authenticate clients directly against the provider, so those sessions bypass the gateway entirely and are untracked.) - **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (default: `data/gomodel.db` when a `./data` directory exists — existing deployments, Docker; otherwise the OS per-user data dir, e.g. `~/.local/share/gomodel/gomodel.db` — see `internal/platformdir`; the local model cache resolves `.cache` vs the OS cache dir the same way), `POSTGRES_URL`, `MONGODB_URL`. `/v1/responses` snapshots and `/v1/conversations` history persist to the configured backend (30-day TTL, hourly sweep); the in-memory fallback stores are byte-capped and used only by embedded setups that skip app wiring. - **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. diff --git a/cmd/gomodel/main.go b/cmd/gomodel/main.go index c326f972e..7caf18cdd 100644 --- a/cmd/gomodel/main.go +++ b/cmd/gomodel/main.go @@ -10,7 +10,7 @@ import ( // @title GoModel API // @version 1.0 -// @description AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, Fireworks AI, Meta, OpenRouter, Kilo AI, DeepSeek, Z.ai, xAI, MiniMax, Xiaomi MiMo, OpenCode Go, Oracle, Ollama, Bailian). Drop-in OpenAI-compatible API. +// @description AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, Fireworks AI, Meta, OpenRouter, OrcaRouter, Kilo AI, DeepSeek, Z.ai, xAI, MiniMax, Xiaomi MiMo, OpenCode Go, Oracle, Ollama, Bailian). Drop-in OpenAI-compatible API. // @BasePath / // @schemes http // @securityDefinitions.apikey BearerAuth diff --git a/config/config.example.yaml b/config/config.example.yaml index 228759ef4..c6a018cba 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", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"] # providers enabled on /p/{provider}/... + enabled_passthrough_providers: ["openai", "anthropic", "cohere", "openrouter", "orcarouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"] # providers enabled on /p/{provider}/... realtime_enabled: true # env: REALTIME_ENABLED; expose /v1/realtime websocket and /p/{provider}/v1/realtime upgrades (OpenAI only) pid_file: "data/gomodel.pid" # env: PID_FILE; where the running gateway records its process id so `gomodel --reload` can find it. Set per instance when several gateways share a host; empty writes no pid file and disables --reload; changing it needs a restart, not a reload @@ -485,6 +485,17 @@ providers: # - openai/gpt-oss-120b # - anthropic/claude-sonnet-4 + # Example: OrcaRouter (OpenAI-compatible AI gateway; ~190 models under + # provider-scoped IDs such as openai/gpt-4o-mini). + # You can also set ORCAROUTER_MODELS="openai/gpt-4o-mini,anthropic/claude-sonnet-4". + # orcarouter: + # type: "orcarouter" + # base_url: "https://api.orcarouter.ai/v1" + # api_key: "${ORCAROUTER_API_KEY}" + # models: + # - openai/gpt-4o-mini + # - anthropic/claude-sonnet-4 + # Example: Kilo AI Gateway. Model IDs use provider/model and are forwarded # unchanged. You can also set KILO_MODELS as a comma-separated env var. # kilo: diff --git a/config/config.go b/config/config.go index b4d0b6e45..7f1898d1c 100644 --- a/config/config.go +++ b/config/config.go @@ -98,6 +98,7 @@ func buildDefaultConfig() *Config { "openai", "anthropic", "openrouter", + "orcarouter", "kilo", "zai", "sglang", diff --git a/config/config_test.go b/config/config_test.go index 1182bc288..ddcba31d5 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -126,7 +126,7 @@ func TestBuildDefaultConfig(t *testing.T) { if !cfg.Server.AllowPassthroughV1Alias { t.Error("expected Server.AllowPassthroughV1Alias=true") } - if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"}; !reflect.DeepEqual(got, want) { + if got, want := cfg.Server.EnabledPassthroughProviders, []string{"openai", "anthropic", "openrouter", "orcarouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"}; !reflect.DeepEqual(got, want) { t.Errorf("expected Server.EnabledPassthroughProviders=%v, got %v", want, got) } if cfg.Models.ConfiguredProviderModelsMode != ConfiguredProviderModelsModeFallback { @@ -1203,7 +1203,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", "cohere", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"} + wantProviders := []string{"openai", "anthropic", "cohere", "openrouter", "orcarouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek", "bailian"} if !reflect.DeepEqual(gotProviders, wantProviders) { t.Fatalf("Server.EnabledPassthroughProviders = %v, want %v", gotProviders, wantProviders) } diff --git a/config/server.go b/config/server.go index e0af13d47..340f8d13a 100644 --- a/config/server.go +++ b/config/server.go @@ -39,7 +39,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", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"]. + // ["openai", "anthropic", "openrouter", "orcarouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"]. 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/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index e148ef840..511f11880 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -272,6 +272,7 @@ Set these to automatically register providers. No YAML configuration required. | `GEMINI_API_KEY` | Google Gemini | | `DEEPSEEK_API_KEY` | DeepSeek | | `OPENROUTER_API_KEY` | OpenRouter | +| `ORCAROUTER_API_KEY` | OrcaRouter | | `KILO_API_KEY` | Kilo AI Gateway | | `ZAI_API_KEY` | Z.ai | | `XAI_API_KEY` | xAI (Grok) | @@ -284,10 +285,10 @@ Set these to automatically register providers. No YAML configuration required. | `VLLM_BASE_URL` | vLLM (no API key needed unless upstream requires) | | `LLMD_BASE_URL` | llm-d Router/EPP (no API key needed unless its Gateway requires one) | -Most providers can use a custom base URL via `_BASE_URL` (for example `OPENAI_BASE_URL`). Chutes AI defaults to `https://llm.chutes.ai/v1` and can be overridden with `CHUTES_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`. Kilo AI defaults to `https://api.kilo.ai/api/gateway` and can be overridden with `KILO_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. SGLang defaults to `http://localhost:30000/v1` when `SGLANG_API_KEY` is set, but keyless deployments should set `SGLANG_BASE_URL` explicitly to register the provider. vLLM follows the same pattern at `http://localhost:8000/v1`. llm-d has no universal endpoint, so `LLMD_BASE_URL` is always required; `LLMD_API_KEY` is optional. 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`). Chutes AI defaults to `https://llm.chutes.ai/v1` and can be overridden with `CHUTES_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`. OrcaRouter defaults to `https://api.orcarouter.ai/v1` and can be overridden with `ORCAROUTER_BASE_URL`. Kilo AI defaults to `https://api.kilo.ai/api/gateway` and can be overridden with `KILO_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. SGLang defaults to `http://localhost:30000/v1` when `SGLANG_API_KEY` is set, but keyless deployments should set `SGLANG_BASE_URL` explicitly to register the provider. vLLM follows the same pattern at `http://localhost:8000/v1`. llm-d has no universal endpoint, so `LLMD_BASE_URL` is always required; `LLMD_API_KEY` is optional. 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`, +`_MODELS`, for example `OPENROUTER_MODELS`, `ORCAROUTER_MODELS`, `ORACLE_MODELS`, `AZURE_MODELS`, `SGLANG_MODELS`, `VLLM_MODELS`, or `LLMD_MODELS`. By default, `CONFIGURED_PROVIDER_MODELS_MODE=fallback` uses configured lists only when upstream `/models` fails, returns nil, or returns an empty list. Set diff --git a/docs/features/passthrough-api.mdx b/docs/features/passthrough-api.mdx index 6e59b89fc..bbe66d58f 100644 --- a/docs/features/passthrough-api.mdx +++ b/docs/features/passthrough-api.mdx @@ -131,7 +131,7 @@ from passthrough requests before forwarding them upstream. Passthrough is intentionally narrow while the API is in beta. -- `openai`, `anthropic`, `openrouter`, `kilo`, `zai`, `sglang`, `vllm`, `llmd`, and `deepseek` are enabled by +- `openai`, `anthropic`, `openrouter`, `orcarouter`, `kilo`, `zai`, `sglang`, `vllm`, `llmd`, and `deepseek` are enabled by default. - Chutes supports passthrough but requires explicit operator opt-in because passthrough can forward provider-native routes that do not identify a model. @@ -150,7 +150,7 @@ Passthrough routes are enabled by default: ```env ENABLE_PASSTHROUGH_ROUTES=true ALLOW_PASSTHROUGH_V1_ALIAS=true -ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,openrouter,kilo,zai,sglang,vllm,llmd,deepseek +ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic,openrouter,orcarouter,kilo,zai,sglang,vllm,llmd,deepseek ``` Set `ENABLED_PASSTHROUGH_PROVIDERS` to the provider types you want to expose. diff --git a/internal/gateway/inference_execute.go b/internal/gateway/inference_execute.go index 707158a2d..016ef51bd 100644 --- a/internal/gateway/inference_execute.go +++ b/internal/gateway/inference_execute.go @@ -201,7 +201,7 @@ func (o *InferenceOrchestrator) CanFastPathStreamingChatPassthrough(workflow *co providerType := strings.ToLower(strings.TrimSpace(workflow.ProviderType)) switch providerType { - case "openai", "azure", "openrouter": + case "openai", "azure", "openrouter", "orcarouter": default: return false } diff --git a/internal/providers/orcarouter/orcarouter.go b/internal/providers/orcarouter/orcarouter.go new file mode 100644 index 000000000..8f6658440 --- /dev/null +++ b/internal/providers/orcarouter/orcarouter.go @@ -0,0 +1,73 @@ +// Package orcarouter provides OrcaRouter integration for the LLM gateway. +package orcarouter + +import ( + "net/http" + "strings" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" + "github.com/enterpilot/gomodel/internal/providers/openai" +) + +const defaultBaseURL = "https://api.orcarouter.ai/v1" + +// Registration provides factory registration for the OrcaRouter provider. +var Registration = providers.Registration{ + Type: "orcarouter", + New: New, + PassthroughSemanticEnricher: passthroughSemanticEnricher, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + }, +} + +// Provider implements the core.Provider interface for OrcaRouter. +// OrcaRouter is an OpenAI-compatible AI gateway that routes to ~190 upstream +// models under provider-scoped IDs (e.g. "openai/gpt-4o-mini"); those IDs pass +// through unchanged. Model listing, chat completions, and responses all follow +// the OpenAI surface, so the shared OpenAI-compatible transport is embedded +// wholesale and only authentication is specialized. +type Provider struct { + *openai.CompatibleProvider +} + +var _ core.Provider = (*Provider)(nil) + +// New creates a new OrcaRouter provider. +func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { + return &Provider{openai.NewCompatibleProvider(cfg.APIKey, opts, openai.CompatibleProviderConfig{ + ProviderName: "orcarouter", + BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), + SetHeaders: setHeaders, + })} +} + +// NewWithHTTPClient creates a new OrcaRouter 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 { + return &Provider{openai.NewCompatibleProviderWithHTTPClient(apiKey, httpClient, hooks, openai.CompatibleProviderConfig{ + ProviderName: "orcarouter", + BaseURL: defaultBaseURL, + SetHeaders: setHeaders, + })} +} + +// setHeaders applies OrcaRouter's bearer-token authentication and forwards the +// gateway's request ID and session ID so routed traffic keeps conversation +// affinity end to end. +func setHeaders(req *http.Request, apiKey string) { + providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{ + AuthScheme: "Bearer ", + RequestIDHeader: "X-Client-Request-Id", + ValidateRequestID: providers.IsValidClientRequestID, + }) + // The session ID keeps a conversation on the same resolved model endpoint + // across requests, maximizing upstream prompt-cache reuse. GoModel's + // session detector already scopes user-supplied IDs before placing them + // in context. + if sessionID := strings.TrimSpace(core.SessionIDFromContext(req.Context())); sessionID != "" { + req.Header.Set("X-Session-Id", sessionID) + } +} diff --git a/internal/providers/orcarouter/orcarouter_test.go b/internal/providers/orcarouter/orcarouter_test.go new file mode 100644 index 000000000..07d585e11 --- /dev/null +++ b/internal/providers/orcarouter/orcarouter_test.go @@ -0,0 +1,111 @@ +package orcarouter + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" +) + +func TestChatCompletion_AuthenticatesWithBearerToken(t *testing.T) { + var gotAuth string + var gotRequestID string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotRequestID = r.Header.Get("X-Client-Request-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-123", + "object":"chat.completion", + "created":1677652288, + "model":"openai/gpt-4o-mini", + "choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + ctx := core.WithRequestID(context.Background(), "req-abc-123") + _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "openai/gpt-4o-mini", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotAuth != "Bearer test-api-key" { + t.Fatalf("authorization = %q, want Bearer test-api-key", gotAuth) + } + if gotRequestID != "req-abc-123" { + t.Fatalf("X-Client-Request-Id = %q, want req-abc-123", gotRequestID) + } +} + +func TestChatCompletion_ForwardsGoModelSessionID(t *testing.T) { + gotSessionID := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSessionID <- r.Header.Get("X-Session-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"chatcmpl-123","object":"chat.completion","created":1677652288, + "model":"openai/gpt-4o-mini", + "choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}] + }`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + ctx := core.WithSessionID(context.Background(), "conversation-42") + _, err := provider.ChatCompletion(ctx, &core.ChatRequest{ + Model: "openai/gpt-4o-mini", + Messages: []core.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("ChatCompletion() error = %v", err) + } + if got := <-gotSessionID; got != "conversation-42" { + t.Fatalf("X-Session-Id = %q, want conversation-42", got) + } +} + +func TestPassthrough_AuthenticatesAndForwardsSessionID(t *testing.T) { + var gotAuth string + var gotSessionID string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotSessionID = r.Header.Get("X-Session-Id") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":"rate limited"}`)) + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + ctx := core.WithSessionID(context.Background(), "conversation-42") + resp, err := provider.Passthrough(ctx, &core.PassthroughRequest{ + Method: http.MethodPost, + Endpoint: "chat/completions", + Body: http.NoBody, + Headers: http.Header{"Content-Type": {"application/json"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if gotAuth != "Bearer test-api-key" { + t.Fatalf("authorization = %q, want Bearer test-api-key", gotAuth) + } + if gotSessionID != "conversation-42" { + t.Fatalf("X-Session-Id = %q, want conversation-42", gotSessionID) + } +} diff --git a/internal/providers/orcarouter/passthrough_semantics.go b/internal/providers/orcarouter/passthrough_semantics.go new file mode 100644 index 000000000..86cda9a0c --- /dev/null +++ b/internal/providers/orcarouter/passthrough_semantics.go @@ -0,0 +1,5 @@ +package orcarouter + +import "github.com/enterpilot/gomodel/internal/providers" + +var passthroughSemanticEnricher = providers.NewOpenAICompatibleSemanticEnricher("orcarouter") diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 4da44adb0..69a266b6d 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -7212,7 +7212,7 @@ func TestProviderPassthrough_RejectsUnsupportedProvider(t *testing.T) { if !strings.Contains(rec.Body.String(), `provider passthrough for \"groq\" is not enabled`) { t.Fatalf("unexpected error body: %s", rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, llmd, openai, openrouter, sglang, vllm, zai") { + if !strings.Contains(rec.Body.String(), "anthropic, deepseek, kilo, llmd, openai, openrouter, orcarouter, sglang, vllm, zai") { t.Fatalf("unexpected error body: %s", rec.Body.String()) } } diff --git a/internal/server/passthrough_support.go b/internal/server/passthrough_support.go index 5bce2f747..62c459146 100644 --- a/internal/server/passthrough_support.go +++ b/internal/server/passthrough_support.go @@ -16,7 +16,7 @@ import ( "github.com/enterpilot/gomodel/internal/usage" ) -var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"} +var defaultEnabledPassthroughProviders = []string{"openai", "anthropic", "openrouter", "orcarouter", "kilo", "zai", "sglang", "vllm", "llmd", "deepseek"} const llmdDroppedReasonHeader = "X-Llm-D-Request-Dropped-Reason" diff --git a/internal/usage/cost.go b/internal/usage/cost.go index c8b326844..4ccc49842 100644 --- a/internal/usage/cost.go +++ b/internal/usage/cost.go @@ -83,6 +83,7 @@ var openAICompatibleTokenCostMappings = []tokenCostMapping{ var providerMappings = map[string][]tokenCostMapping{ "openai": openAICompatibleTokenCostMappings, "openrouter": openAICompatibleTokenCostMappings, + "orcarouter": openAICompatibleTokenCostMappings, "anthropic": { {rawDataKey: "cache_read_input_tokens", pricingField: func(p *core.ModelPricing) *float64 { return p.CachedInputPerMtok }, side: sideInput, unit: unitPerMtok}, {rawDataKey: "cache_creation_input_tokens", pricingField: func(p *core.ModelPricing) *float64 { return p.CacheWritePerMtok }, side: sideInput, unit: unitPerMtok}, diff --git a/run/providers.go b/run/providers.go index 8df1ff882..58e999e8c 100644 --- a/run/providers.go +++ b/run/providers.go @@ -26,6 +26,7 @@ import ( "github.com/enterpilot/gomodel/internal/providers/opencodego" "github.com/enterpilot/gomodel/internal/providers/openrouter" "github.com/enterpilot/gomodel/internal/providers/oracle" + "github.com/enterpilot/gomodel/internal/providers/orcarouter" "github.com/enterpilot/gomodel/internal/providers/sglang" "github.com/enterpilot/gomodel/internal/providers/vertex" "github.com/enterpilot/gomodel/internal/providers/vllm" @@ -45,6 +46,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(openai.Registration) factory.Add(openrouter.Registration) + factory.Add(orcarouter.Registration) factory.Add(azure.Registration) factory.Add(bailian.Registration) factory.Add(oracle.Registration) diff --git a/run/providers_test.go b/run/providers_test.go index 8522ed741..8508b0a9e 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -50,6 +50,13 @@ func TestDefaultProviderFactoryCredentialForms(t *testing.T) { fields: []string{"api_keys", "base_url", "session_sticky_keys", "models"}, required: []string{"api_keys"}, }, + { + // OpenAI-compatible gateway; plain API-key shape. + providerType: "orcarouter", + defaultURL: "https://api.orcarouter.ai/v1", + fields: []string{"api_keys", "base_url", "session_sticky_keys", "models"}, + required: []string{"api_keys"}, + }, { // A deployment URL is the provider, so it is required, and Azure // is the one type that takes an API version. @@ -177,7 +184,7 @@ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chutes", "cohere", "deepseek", "elevenlabs", "fireworks", "gemini", "groq", "kilo", "kimicode", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", - "openrouter", "oracle", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", + "openrouter", "oracle", "orcarouter", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", } for _, metricsEnabled := range []bool{false, true} {