diff --git a/agent-schema.json b/agent-schema.json index d738d42c8..9d5dcde85 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -224,7 +224,7 @@ "properties": { "provider": { "type": "string", - "description": "The underlying provider type. Defaults to \"openai\" when not set. Supported values: openai, anthropic, google, amazon-bedrock, dmr, and any built-in alias (requesty, openrouter, opper, runcrate, api-route, atlascloud, azure, xai, ollama, mistral, baseten, daoxe, ovhcloud, groq, fireworks-ai, deepseek, cerebras, togetherai, huggingface, moonshotai, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, nvidia, github-copilot, chatgpt, opencode, opencode-go, etc.).", + "description": "The underlying provider type. Defaults to \"openai\" when not set. Supported values: openai, anthropic, google, amazon-bedrock, dmr, and any built-in alias (requesty, openrouter, opper, runcrate, api-route, orcarouter, atlascloud, azure, xai, ollama, mistral, baseten, daoxe, ovhcloud, groq, fireworks-ai, deepseek, cerebras, togetherai, huggingface, moonshotai, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, nvidia, github-copilot, chatgpt, opencode, opencode-go, etc.).", "examples": [ "openai", "anthropic", diff --git a/docs/configuration/models/index.md b/docs/configuration/models/index.md index 1efabfbeb..51124d69e 100644 --- a/docs/configuration/models/index.md +++ b/docs/configuration/models/index.md @@ -18,7 +18,7 @@ models: first_available: [list] # Optional: candidate model refs, tried in order by available credentials. # Mutually exclusive with other model settings. provider: string # Required unless using first_available. One of: openai, anthropic, google, amazon-bedrock, - # dmr, mistral, xai, nebius, nvidia, minimax, baseten, daoxe, ovhcloud, groq, fireworks-ai, deepseek, cerebras, togetherai, huggingface, moonshotai, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, requesty, openrouter, opper, runcrate, api-route, atlascloud, + # dmr, mistral, xai, nebius, nvidia, minimax, baseten, daoxe, ovhcloud, groq, fireworks-ai, deepseek, cerebras, togetherai, huggingface, moonshotai, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, requesty, openrouter, opper, runcrate, api-route, orcarouter, atlascloud, # azure, ollama, github-copilot, or a named provider defined # under the top-level `providers:` section. model: string # Required: model identifier @@ -60,7 +60,7 @@ models: | Property | Type | Required | Description | | --------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- | | `first_available` | array | ✗ | Candidate model references tried in order; selects the first whose credentials are configured. Mutually exclusive with other model settings. | -| `provider` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Provider: `openai`, `anthropic`, `google`, `amazon-bedrock`, `dmr`, `mistral`, `xai`, `nebius`, `nvidia`, `minimax`, `baseten`, `daoxe`, `ovhcloud`, `groq`, `fireworks-ai`, `deepseek`, `cerebras`, `togetherai`, `huggingface`, `moonshotai`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `opper`, `runcrate`, `api-route`, `atlascloud`, `azure`, `ollama`, `github-copilot`, `chatgpt`, `opencode`, `opencode-go`, or any [named provider](../../providers/custom/index.md). | +| `provider` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Provider: `openai`, `anthropic`, `google`, `amazon-bedrock`, `dmr`, `mistral`, `xai`, `nebius`, `nvidia`, `minimax`, `baseten`, `daoxe`, `ovhcloud`, `groq`, `fireworks-ai`, `deepseek`, `cerebras`, `togetherai`, `huggingface`, `moonshotai`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `opper`, `runcrate`, `api-route`, `orcarouter`, `atlascloud`, `azure`, `ollama`, `github-copilot`, `chatgpt`, `opencode`, `opencode-go`, or any [named provider](../../providers/custom/index.md). | | `model` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Model name (e.g., `gpt-4o`, `claude-sonnet-4-5`, `gemini-3.5-flash`) | | `description` | string | ✗ | Informational, human-readable summary of the model's purpose or strengths (e.g., "fast and cheap, good for summaries"). Not sent to the model. Can be combined with `first_available` (a selector's description is kept when it resolves). | | `temperature` | float | ✗ | Sampling randomness. Range is provider-dependent — typically `0.0–2.0` (Anthropic caps at `1.0`). `0.0` is deterministic. | @@ -558,7 +558,7 @@ for a complete local-server configuration. ## Custom HTTP Headers For OpenAI-compatible providers (`openai`, `github-copilot`, `mistral`, `xai`, -`nebius`, `nvidia`, `minimax`, `baseten`, `daoxe`, `ovhcloud`, `groq`, `fireworks-ai`, `deepseek`, `cerebras`, `togetherai`, `huggingface`, `moonshotai`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `opper`, `runcrate`, `atlascloud`, `ollama`, and any custom provider using the OpenAI API), +`nebius`, `nvidia`, `minimax`, `baseten`, `daoxe`, `ovhcloud`, `groq`, `fireworks-ai`, `deepseek`, `cerebras`, `togetherai`, `huggingface`, `moonshotai`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `opper`, `runcrate`, `orcarouter`, `atlascloud`, `ollama`, and any custom provider using the OpenAI API), `provider_opts.http_headers` adds arbitrary HTTP headers to every outgoing request: diff --git a/docs/data/nav.yml b/docs/data/nav.yml index 4ffc6e78f..66970a059 100644 --- a/docs/data/nav.yml +++ b/docs/data/nav.yml @@ -231,6 +231,8 @@ url: /providers/opper/ - title: OpenRouter url: /providers/openrouter/ + - title: OrcaRouter + url: /providers/orcarouter/ - title: OVHcloud url: /providers/ovhcloud/ - title: Runcrate diff --git a/docs/providers/orcarouter/index.md b/docs/providers/orcarouter/index.md new file mode 100644 index 000000000..cee70edf6 --- /dev/null +++ b/docs/providers/orcarouter/index.md @@ -0,0 +1,88 @@ +--- +title: "OrcaRouter" +description: "Use OrcaRouter models with Docker Agent." +keywords: docker agent, ai agents, model providers, llm, orcarouter +weight: 231 +canonical: https://docs.docker.com/ai/docker-agent/providers/orcarouter/ +--- + +_Use OrcaRouter models with Docker Agent._ + +## Overview + +[OrcaRouter](https://www.orcarouter.ai) provides access to models from many providers through an OpenAI-compatible API. Docker Agent includes built-in support for OrcaRouter as an alias provider. + +## Setup + +1. Get an API key from [OrcaRouter](https://www.orcarouter.ai) +2. Set the environment variable: + + ```bash + $ export ORCAROUTER_API_KEY=your-api-key + ``` + +## Usage + +### Inline Syntax + +Set the provider and model in the agent definition: + +```yaml +agents: + root: + model: orcarouter/orcarouter/auto + description: Assistant using OrcaRouter + instruction: You are a helpful assistant. +``` + +`orcarouter/auto` is OrcaRouter's adaptive router. You can also reference a model served by the gateway with its upstream provider prefix, such as `orcarouter/anthropic/claude-sonnet-4.5` or `orcarouter/deepseek/deepseek-v4-pro`. Docker Agent splits only the first slash, so the full upstream model ID is preserved. + +### Named Model + +For more control over parameters: + +```yaml +models: + orca_router: + provider: orcarouter + model: orcarouter/auto + temperature: 0.7 + max_tokens: 8192 + +agents: + root: + model: orca_router + description: Assistant using OrcaRouter + instruction: You are a helpful assistant. +``` + +## Pricing and Model Metadata + +Docker Agent fetches OrcaRouter model metadata from [models.dev](https://models.dev/), including pricing per 1M input/output tokens, cache pricing when available, context limits, output limits, and modalities. This powers cost tracking and the model picker in the same way as other first-class providers. + +If models.dev is unavailable, Docker Agent falls back to its embedded catalog snapshot. + +## How It Works + +OrcaRouter is implemented as a built-in alias in Docker Agent: + +- **API Type:** OpenAI-compatible (`openai`) +- **Base URL:** `https://api.orcarouter.ai/v1` +- **Token Variable:** `ORCAROUTER_API_KEY` + +## Example: Code Assistant + +```yaml +agents: + coder: + model: orcarouter/orcarouter/auto + description: Code assistant using OrcaRouter + instruction: | + You are an expert programmer. + Write clean, maintainable code. + Explain trade-offs when helpful. + toolsets: + - type: filesystem + - type: shell + - type: think +``` diff --git a/docs/providers/overview/index.md b/docs/providers/overview/index.md index 0ca9d921b..154bc4dda 100644 --- a/docs/providers/overview/index.md +++ b/docs/providers/overview/index.md @@ -69,6 +69,7 @@ Use this table to find a built-in provider's config key and authentication metho | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | | Opper | `opper` | `OPPER_API_KEY` | | API Route | `api-route` | `API_ROUTE_API_KEY` | +| OrcaRouter | `orcarouter` | `ORCAROUTER_API_KEY` | | Atlas Cloud | [`atlascloud`](../atlascloud/index.md) | `ATLASCLOUD_API_KEY` | | Runcrate | [`runcrate`](../runcrate/index.md) | `RUNCRATE_API_KEY` | | Azure OpenAI | `azure` | `AZURE_API_KEY` + `base_url` (override the key with `token_key`) | diff --git a/examples/README.md b/examples/README.md index 6cb1f843b..c58b94644 100644 --- a/examples/README.md +++ b/examples/README.md @@ -211,6 +211,7 @@ remote MCP endpoints. | [`baseten.yaml`](baseten.yaml) | Baseten cloud provider. | | [`daoxe.yaml`](daoxe.yaml) | DaoXE multi-model gateway provider. | | [`opper.yaml`](opper.yaml) | Opper multi-model gateway provider. | +| [`orcarouter.yaml`](orcarouter.yaml) | OrcaRouter multi-model gateway provider. | | [`ovhcloud.yaml`](ovhcloud.yaml) | OVHcloud AI Endpoints provider. | | [`runcrate.yaml`](runcrate.yaml) | Runcrate open-model host (`runcrate`). | | [`groq.yaml`](groq.yaml) | Groq fast-inference provider. | diff --git a/examples/orcarouter.yaml b/examples/orcarouter.yaml new file mode 100644 index 000000000..6f7b24b6a --- /dev/null +++ b/examples/orcarouter.yaml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=../agent-schema.json + +models: + orcarouter_model: + provider: orcarouter + model: orcarouter/auto + +agents: + root: + model: orcarouter_model + description: Assistant using OrcaRouter + instruction: | + You are a helpful assistant. + toolsets: + - type: filesystem + - type: shell + - type: think diff --git a/pkg/config/auto.go b/pkg/config/auto.go index e0d39c670..195bf1506 100644 --- a/pkg/config/auto.go +++ b/pkg/config/auto.go @@ -63,6 +63,7 @@ var cloudProviders = []providerConfig{ {"mistral", []string{"MISTRAL_API_KEY"}, "MISTRAL_API_KEY", "MISTRAL_API_KEY"}, {"openrouter", []string{"OPENROUTER_API_KEY"}, "OPENROUTER_API_KEY", "OPENROUTER_API_KEY"}, {"api-route", []string{"API_ROUTE_API_KEY"}, "API_ROUTE_API_KEY", "API_ROUTE_API_KEY"}, + {"orcarouter", []string{"ORCAROUTER_API_KEY"}, "ORCAROUTER_API_KEY", "ORCAROUTER_API_KEY"}, {"atlascloud", []string{"ATLASCLOUD_API_KEY"}, "ATLASCLOUD_API_KEY", "ATLASCLOUD_API_KEY"}, {"opper", []string{"OPPER_API_KEY"}, "OPPER_API_KEY", "OPPER_API_KEY"}, {"runcrate", []string{"RUNCRATE_API_KEY"}, "RUNCRATE_API_KEY", "RUNCRATE_API_KEY"}, @@ -164,6 +165,7 @@ var DefaultModels = map[string]string{ "mistral": "mistral-small-latest", "openrouter": "meta-llama/llama-4-maverick", "api-route": "gpt-6.1-sol", + "orcarouter": "orcarouter/auto", "atlascloud": "qwen/qwen3.8-max", "opper": "claude-sonnet-4-6", "runcrate": "deepseek-ai/DeepSeek-V4.1-Flash", diff --git a/pkg/config/auto_test.go b/pkg/config/auto_test.go index 73b7ab08d..767083232 100644 --- a/pkg/config/auto_test.go +++ b/pkg/config/auto_test.go @@ -81,6 +81,13 @@ func TestAvailableProviders_NoGateway(t *testing.T) { }, expectedProvider: "api-route", }, + { + name: "orcarouter api key present", + envVars: map[string]string{ + "ORCAROUTER_API_KEY": "test-key", + }, + expectedProvider: "orcarouter", + }, { name: "atlascloud api key present", envVars: map[string]string{ @@ -368,6 +375,15 @@ func TestAutoModelConfig(t *testing.T) { expectedModel: "deepseek-ai/DeepSeek-V4.1-Flash", expectedMaxTokens: 32000, }, + { + name: "orcarouter provider", + envVars: map[string]string{ + "ORCAROUTER_API_KEY": "test-key", + }, + expectedProvider: "orcarouter", + expectedModel: "orcarouter/auto", + expectedMaxTokens: 32000, + }, { name: "baseten provider", envVars: map[string]string{ @@ -549,7 +565,7 @@ func TestDefaultModels(t *testing.T) { t.Parallel() // Test that DefaultModels map has all expected providers - expectedProviders := []string{"openai", "anthropic", "google", "dmr", "mistral", "openrouter", "atlascloud", "baseten", "ovhcloud", "groq", "fireworks-ai", "deepseek", "cerebras", "togetherai", "huggingface", "moonshotai", "vercel", "amazon-bedrock", "opencode", "opencode-go", "github-copilot"} + expectedProviders := []string{"openai", "anthropic", "google", "dmr", "mistral", "openrouter", "api-route", "orcarouter", "atlascloud", "baseten", "ovhcloud", "groq", "fireworks-ai", "deepseek", "cerebras", "togetherai", "huggingface", "moonshotai", "vercel", "amazon-bedrock", "opencode", "opencode-go", "github-copilot"} for _, provider := range expectedProviders { t.Run(provider, func(t *testing.T) { @@ -567,6 +583,7 @@ func TestDefaultModels(t *testing.T) { assert.Equal(t, "ai/qwen3:latest", DefaultModels["dmr"]) assert.Equal(t, "mistral-small-latest", DefaultModels["mistral"]) assert.Equal(t, "meta-llama/llama-4-maverick", DefaultModels["openrouter"]) + assert.Equal(t, "orcarouter/auto", DefaultModels["orcarouter"]) assert.Equal(t, "qwen/qwen3.8-max", DefaultModels["atlascloud"]) assert.Equal(t, "deepseek-ai/DeepSeek-V4-Pro-0813", DefaultModels["baseten"]) assert.Equal(t, "Qwen3.5-397B-A17B", DefaultModels["ovhcloud"]) @@ -587,7 +604,7 @@ func TestAutoModelConfig_IntegrationWithDefaultModels(t *testing.T) { t.Parallel() // Verify that AutoModelConfig always returns a model from DefaultModels - providers := []string{"openai", "anthropic", "google", "mistral", "openrouter", "atlascloud", "baseten", "ovhcloud", "groq", "fireworks-ai", "deepseek", "cerebras", "togetherai", "huggingface", "moonshotai", "vercel", "opencode", "github-copilot"} + providers := []string{"openai", "anthropic", "google", "mistral", "openrouter", "api-route", "orcarouter", "atlascloud", "baseten", "ovhcloud", "groq", "fireworks-ai", "deepseek", "cerebras", "togetherai", "huggingface", "moonshotai", "vercel", "opencode", "github-copilot"} for _, provider := range providers { t.Run(provider, func(t *testing.T) { @@ -609,6 +626,10 @@ func TestAutoModelConfig_IntegrationWithDefaultModels(t *testing.T) { envVars["MISTRAL_API_KEY"] = "test-key" case "openrouter": envVars["OPENROUTER_API_KEY"] = "test-key" + case "api-route": + envVars["API_ROUTE_API_KEY"] = "test-key" + case "orcarouter": + envVars["ORCAROUTER_API_KEY"] = "test-key" case "atlascloud": envVars["ATLASCLOUD_API_KEY"] = "test-key" case "baseten": diff --git a/pkg/model/provider/aliases.go b/pkg/model/provider/aliases.go index 3da14c464..aec856a36 100644 --- a/pkg/model/provider/aliases.go +++ b/pkg/model/provider/aliases.go @@ -75,6 +75,11 @@ var Aliases = map[string]Alias{ BaseURL: "https://api.runcrate.ai/v1", TokenEnvVar: "RUNCRATE_API_KEY", }, + "orcarouter": { + APIType: "openai", + BaseURL: "https://api.orcarouter.ai/v1", + TokenEnvVar: "ORCAROUTER_API_KEY", + }, "atlascloud": { APIType: "openai", BaseURL: "https://api.atlascloud.ai/v1", diff --git a/pkg/model/provider/aliases_test.go b/pkg/model/provider/aliases_test.go index a625da231..85a91a72e 100644 --- a/pkg/model/provider/aliases_test.go +++ b/pkg/model/provider/aliases_test.go @@ -40,6 +40,7 @@ func TestCatalogAliases(t *testing.T) { expected := map[string]Alias{ "openrouter": {APIType: "openai", BaseURL: "https://openrouter.ai/api/v1", TokenEnvVar: "OPENROUTER_API_KEY"}, "api-route": {APIType: "openai", BaseURL: "https://global.api-route.com/v1", TokenEnvVar: "API_ROUTE_API_KEY"}, + "orcarouter": {APIType: "openai", BaseURL: "https://api.orcarouter.ai/v1", TokenEnvVar: "ORCAROUTER_API_KEY"}, "atlascloud": {APIType: "openai", BaseURL: "https://api.atlascloud.ai/v1", TokenEnvVar: "ATLASCLOUD_API_KEY"}, "runcrate": {APIType: "openai", BaseURL: "https://api.runcrate.ai/v1", TokenEnvVar: "RUNCRATE_API_KEY"}, "opper": {APIType: "openai", BaseURL: "https://api.opper.ai/v3/compat", TokenEnvVar: "OPPER_API_KEY"}, diff --git a/pkg/model/provider/openai/client.go b/pkg/model/provider/openai/client.go index 0f9e53469..f4f5a907c 100644 --- a/pkg/model/provider/openai/client.go +++ b/pkg/model/provider/openai/client.go @@ -283,6 +283,7 @@ var openModelHostProviders = map[string]bool{ "openrouter": true, "opper": true, "runcrate": true, + "orcarouter": true, "nebius": true, "nvidia": true, "cerebras": true, diff --git a/pkg/model/provider/openai/system_message_merge_test.go b/pkg/model/provider/openai/system_message_merge_test.go index 70dcb43e7..c3773b4a9 100644 --- a/pkg/model/provider/openai/system_message_merge_test.go +++ b/pkg/model/provider/openai/system_message_merge_test.go @@ -35,6 +35,16 @@ func TestChatCompletions_MergesConsecutiveSystemMessages(t *testing.T) { }) } +func TestOrcaRouter_MergesConsecutiveSystemMessages(t *testing.T) { + t.Parallel() + + assertMergesConsecutiveMessages(t, &latest.ModelConfig{ + Provider: "orcarouter", + Model: "orcarouter/auto", + TokenKey: "MY_TOKEN", + }) +} + func TestDaoxe_MergesConsecutiveSystemMessages(t *testing.T) { t.Parallel() diff --git a/pkg/model/provider/openai_alias_providers_test.go b/pkg/model/provider/openai_alias_providers_test.go index f5b34d7ca..845999bea 100644 --- a/pkg/model/provider/openai_alias_providers_test.go +++ b/pkg/model/provider/openai_alias_providers_test.go @@ -64,6 +64,15 @@ var openAIAliasProviders = []openAIAliasProvider{ mergesSystemMessages: true, liveOptInEnvVar: "DOCKER_AGENT_RUN_API_ROUTE_LIVE_TEST", }, + { + provider: "orcarouter", + envVar: "ORCAROUTER_API_KEY", + testKey: "test-orcarouter-key", + model: "orcarouter/auto", + greeting: "Hello from OrcaRouter", + mergesSystemMessages: true, + liveOptInEnvVar: "DOCKER_AGENT_RUN_ORCAROUTER_LIVE_TEST", + }, { provider: "atlascloud", envVar: "ATLASCLOUD_API_KEY", @@ -319,7 +328,8 @@ func TestOpenAIAliasProvider_EndToEndRequest(t *testing.T) { // // DEEPSEEK_API_KEY=sk-... go test -run TestOpenAIAliasProvider_LiveAPI ./pkg/model/provider/ // -// API Route additionally requires DOCKER_AGENT_RUN_API_ROUTE_LIVE_TEST=1. +// API Route additionally requires DOCKER_AGENT_RUN_API_ROUTE_LIVE_TEST=1; +// OrcaRouter requires DOCKER_AGENT_RUN_ORCAROUTER_LIVE_TEST=1. func TestOpenAIAliasProvider_LiveAPI(t *testing.T) { for _, p := range openAIAliasProviders { t.Run(p.provider, func(t *testing.T) { @@ -329,17 +339,31 @@ func TestOpenAIAliasProvider_LiveAPI(t *testing.T) { } func TestAPIRouteLiveAPIRequiresExplicitOptIn(t *testing.T) { + assertLiveAPIRequiresExplicitOptIn(t, "api-route") +} + +func TestOrcaRouterLiveAPIRequiresExplicitOptIn(t *testing.T) { + assertLiveAPIRequiresExplicitOptIn(t, "orcarouter") +} + +func assertLiveAPIRequiresExplicitOptIn(t *testing.T, providerName string) { + t.Helper() var p openAIAliasProvider for _, candidate := range openAIAliasProviders { - if candidate.provider == "api-route" { + if candidate.provider == providerName { p = candidate break } } require.NotEmpty(t, p.liveOptInEnvVar) var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + var mu sync.Mutex + var hosts []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) + mu.Lock() + hosts = append(hosts, r.Host) + mu.Unlock() w.Header().Set("Content-Type", "text/event-stream") writeSSEChunk(w, map[string]any{ "id": "chatcmpl-test", "object": "chat.completion.chunk", "model": p.model, @@ -364,6 +388,9 @@ func TestAPIRouteLiveAPIRequiresExplicitOptIn(t *testing.T) { runOpenAIAliasProviderLiveAPI(t, p, server.URL) }) require.Equal(t, int32(1), requests.Load()) + mu.Lock() + defer mu.Unlock() + require.Equal(t, []string{strings.TrimPrefix(server.URL, "http://")}, hosts) } func runOpenAIAliasProviderLiveAPI(t *testing.T, p openAIAliasProvider, baseURL string) {