Skip to content
Router One

Connect DSPy to Router One through dspy.LM, the openai/ prefix, and one api_base

DSPy (stanfordnlp/dspy) is an open-source Python framework for programming, rather than prompting, language models: you declare a signature such as question -> answer, wrap it in a module (dspy.Predict, dspy.ChainOfThought, dspy.ReAct), and optionally let an optimizer rewrite the prompts against a metric. Every model call goes through one class, dspy.LM, which takes a LiteLLM-style provider/model string plus api_base and api_key. With the openai/ prefix and Router One's /v1 base URL, each call is one POST /v1/chat/completions, so one key reaches every chat model in the catalog with a cost and latency trace per request in Dashboard → Logs, while signatures, adapters, tools, metrics, and optimizers keep running in your process. This guide covers the install, the model-string rule that breaks most first runs, which DSPy setting sends which request, why DSPy's response cache leaves fewer rows in Logs than calls in your code, and how to cap an optimizer run with a dedicated key. Client-side behavior was checked against DSPy 3.3.1, the current release on PyPI.

Install dspy and set your credentials

Use Python 3.10 or newer: the package metadata on PyPI requires >=3.10 and <3.15, and the DSPy setup page says it works in Python 3.10+ environments. pip install dspy (or uv add dspy) is the documented install; litellm and the openai package are declared dependencies, so nothing else is needed for an OpenAI-compatible endpoint. pip picks the latest stable release, 3.3.1 when this guide was checked; 3.4.0b1 is a pre-release that pip skips unless you ask for it. The shell example below is for macOS/Linux. Replace both placeholders before running it, using a Router One key and the exact ID of a current chat model. The ROUTER_ONE_* names belong to this example, which reads them explicitly; OPENAI_API_KEY is not needed, because the key is passed to dspy.LM as api_key, and the api_key and api_base arguments are used even when OPENAI_API_KEY or OPENAI_API_BASE is set in the environment. Keep the same environment active when running the Python file.

terminal
python -m pip install -U dspy
export ROUTER_ONE_API_KEY="sk-your-router-one-key"
export ROUTER_ONE_MODEL_ID="<exact-model-id-from-/models>"

Configure DSPy to use the Router One base URL

Save this as program.py and run python program.py. dspy.LM takes a LiteLLM-style provider/model string, and its first segment must be openai/: the LiteLLM docs define that prefix as the instruction to call an OpenAI /chat/completions endpoint, and the string is split at the first slash only, so everything after openai/ is sent unchanged as the request's model field. The example builds it from ROUTER_ONE_MODEL_ID, which is why a GPT-family ID such as openai/gpt-5.5 becomes openai/openai/gpt-5.5 and a Claude ID becomes openai/anthropic/claude-sonnet-5. api_base is the /v1 base URL with nothing appended; the OpenAI client underneath adds /chat/completions itself. api_key is the Router One key. model_type='chat' is the default and is written out only to make the endpoint explicit. cache=False is there for the first runs: DSPy caches responses in memory and on disk by default, so without it a second run of the same file is answered locally and sends nothing. dspy.configure(lm=lm, track_usage=True) makes this LM the default for every module and turns on token accounting, which result.get_lm_usage() returns per model string. dspy.Predict sends one request for the signature question -> answer; dspy.ChainOfThought adds a reasoning output field and is still one request; dspy.ReAct sends one request per iteration and one more that extracts the final answer, while the add function runs in your process. In 3.3.1 temperature and max_tokens default to None and are left out of the request, so the model's own defaults apply. dspy.inspect_history(n=1) prints the last prompt and reply as DSPy sent them, and lm.history keeps one record per call with its usage:

program.py
import os

import dspy

# openai/ selects the OpenAI Chat Completions route; the rest of the string is sent
# unchanged as the model field (openai/openai/gpt-5.5 for a GPT-family ID).
lm = dspy.LM(
    "openai/" + os.environ["ROUTER_ONE_MODEL_ID"],
    api_base="https://api.router.one/v1",  # ends in /v1, nothing after it
    api_key=os.environ["ROUTER_ONE_API_KEY"],
    model_type="chat",  # POST /v1/chat/completions
    cache=False,  # first runs: every call reaches the gateway and shows up in Logs
)
dspy.configure(lm=lm, track_usage=True)

# 1. One Predict call is one request
qa = dspy.Predict("question -> answer")
result = qa(question="What is the capital of France?")
print(result.answer, result.get_lm_usage())

# 2. ChainOfThought adds a reasoning output field; still one request
cot = dspy.ChainOfThought("question -> answer")
print(cot(question="What is 17 * 23?").answer)


# 3. ReAct: one request per iteration plus one that extracts the answer; the tool runs in your process
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b


agent = dspy.ReAct("question -> answer", tools=[add], max_iters=5)
print(agent(question="What is 1234 + 4321?").answer)

# The last call as DSPy sent it, and the per-call records DSPy keeps
dspy.inspect_history(n=1)
print(len(lm.history), lm.history[-1]["usage"])

Which DSPy setting sends which request

dspy.LM carries the connection; modules and adapters decide what each request looks like. The rows give the value for each setting in this guide, the request it produces, and what to confirm before relying on it.

DSPy settingValueWhat to verify
dspy.LM(model=…)openai/ followed by the exact catalog ID: openai/anthropic/claude-sonnet-5, openai/deepseek-v4.1-flash, or openai/openai/gpt-5.5 for a GPT-family IDThe model in each Logs trace is the ID without the leading openai/; only the first slash is a separator, so a prefix that belongs to the ID is kept
dspy.LM(api_base=…)https://api.router.one/v1Ends in /v1 with nothing after it; without /v1 the request goes to /chat/completions and the gateway answers 404 not_found, which DSPy 3.3.1 raises as dspy.LMUnsupportedModelError although the model ID is fine
dspy.LM(api_key=…)A Router One key created for this program, with maxSpend, read from ROUTER_ONE_API_KEYThe first trace appears in Dashboard → Logs under that key; the argument is used even when OPENAI_API_KEY is set, and an invalid key surfaces as dspy.LMAuthError with status 401
model_type (default chat)chat sends POST /v1/chat/completions, which serves every chat model in the catalog; responses sends POST /v1/responsesUse responses only with the currently listed GPT-family and DeepSeek IDs, the families that endpoint serves; any other ID gets a 400 whose message names the endpoint to call
temperature, max_tokens (default None)Left out of the request unless you set themFor names DSPy treats as OpenAI reasoning models it rejects a non-zero temperature other than 1.0 and a max_tokens below 16000, and sends max_completion_tokens instead of max_tokens; the FAQ below lists which IDs match
cache (default True)An identical request is answered from DSPy's memory or disk cache (~/.dspy_cache, or DSPY_CACHEDIR) and nothing is sentNo row in Logs and an empty get_lm_usage() for that call; pass cache=False, or a new rollout_id with a non-zero temperature, to force a request
dspy.Predict, dspy.ChainOfThought, dspy.ReActOne request per Predict or ChainOfThought call; ReAct sends one per iteration up to max_iters (20 in the 3.3.1 signature) plus one that extracts the answerNo tools field is sent: ReAct describes its tools in the prompt and parses next_tool_name and next_tool_args from text, so tool calling is not required and the functions run in your process
adapter (default ChatAdapter)ChatAdapter sends plain messages with [[ ## field ## ]] markers and, when a reply does not parse, repeats the call through JSONAdapter; JSONAdapter adds response_formatJSONAdapter picks json_schema or json_object from LiteLLM's local model metadata, not from the gateway; check structured outputs on the model detail page, and expect a rejected response_format as dspy.LMInvalidRequestError rather than a silent retry in JSON mode

Budget an optimizer run and reconcile it with Logs

A DSPy program at inference time is a handful of requests; compile() is where spend concentrates. An optimizer runs your program over the training and validation examples many times: the DSPy FAQ reports one BootstrapFewShotWithRandomSearch compile at about 3,200 API calls, 2.7 million input tokens, and 156,000 output tokens, and the optimizer guide calls compile() expensive for MIPROv2 and GEPA. The budget knobs are client-side and count work, not money: MIPROv2's auto setting (light by default, then medium and heavy) sets 6, 12, or 18 candidates and caps the validation set at 100, 300, or 1,000 examples; GEPA requires exactly one of auto, max_full_evals, or max_metric_calls and logs the approximate number of metric calls before it starts. GEPA's reflection_lm and MIPROv2's prompt_model are separate dspy.LM objects, so give them the same openai/ prefix and api_base, or a key of their own if you want proposal spend separated from task spend. Concurrency comes from num_threads: dspy.Evaluate and the optimizers fall back to dspy.settings.num_threads, which is 8, so up to eight requests are in flight during an evaluation. If the key's rateLimit is lower than that burst, the gateway answers 429 and DSPy raises dspy.LMRateLimitError after its retries; lower num_threads or raise the key's rateLimit rather than adding retries. Retries multiply requests: num_retries defaults to 3, and against a local mock server DSPy 3.3.1 with LiteLLM 1.101 sent a request answered with 429 or 500 seven times, and one answered with 400, 401, 402, or 404 four times, before raising, while num_retries=0 sent it once. Every attempt that reaches the gateway is its own request in Dashboard → Logs with its own request_id. Give each program, and each compile run you want to bound, a dedicated key with maxSpend: at the cap the next call gets HTTP 402, DSPy raises dspy.LMBillingError, the parallel executor stops after max_errors failed examples (10 by default) with the message Execution cancelled due to errors or interruption, and the wallet and your other keys are untouched. Expect fewer rows in Logs than calls in your code while cache=True: identical requests, including a whole repeated compile, are replayed from disk, and the cache key ignores api_key and api_base, so an answer cached for the same model string on another endpoint is replayed too. get_lm_usage() is DSPy's tally of the usage the API reported and is empty for cached calls; the cost field in lm.history is LiteLLM's estimate from its own price list and can be None for IDs it does not know. The charge is what Logs records: reconcile by key, time, exact model, and token counts, and keep the request_id when reporting a failure. Router One serves the model requests and records their traces; the optimizer, the metric, the tools, and the cache all run in your process.

Embeddings and fine-tuning stay outside the gateway

dspy.Embedder accepts either a hosted model string or a Python callable. A hosted string such as openai/text-embedding-3-small goes through LiteLLM's embedding call, and with Router One's api_base that is POST /v1/embeddings, an endpoint the gateway does not serve, so the call fails with a 404 that LiteLLM raises as NotFoundError. Keep embeddings on another path: the Embedder reference shows a local sentence-transformers model passed as a callable, dspy.Embedder(model.encode), and a hosted embedding model from another provider works with that provider's own key and api_base. dspy.retrievers.Embeddings takes the embedder as an argument, so a RAG program can retrieve with a local or third-party embedder and send only its generation calls to Router One through dspy.LM. The two objects are independent: dspy.configure(lm=...) does not touch the embedder, and the embedder's requests never appear in Logs. The same boundary applies to training: optimizers that train weights, such as BootstrapFinetune, need a fine-tuning API, which Router One does not serve, while the prompt optimizers (BootstrapFewShot, MIPROv2, GEPA) need only chat calls.

Which model ID should DSPy send?

Copy the exact model ID from /models, preserving case, hyphens, and version suffixes; do not substitute a display name. Open its detail page and match the supported API endpoints, context window, and capabilities such as tool calling to the provider and features selected in DSPy. A catalog listing does not mean the client can use every feature of that model. Give each tool a dedicated API key with a maxSpend cap.

Which API protocol is DSPy using?

OpenAI-compatible describes an interface format; it does not make Chat Completions (/v1/chat/completions), Responses (/v1/responses), and Anthropic Messages (/v1/messages) interchangeable. Check the installed client version, provider configuration, and actual request path against the model detail page and API compatibility fact sheet. A successful plain-text chat does not establish support for hosted tools, conversation state, or file-editing features.

Verify the DSPy call in your request trace

Send a simple text request from DSPy, then match its trace in Dashboard → Logs by time, model, and request_id: tokens, cost, latency, and status. Next, test streaming, tool calls, and multi-turn history separately. For failures, retain the actual request path, full error message, and request_id. If there is no matching log, check client configuration and connectivity before attributing the error to the gateway or upstream.

FAQ

dspy.LM fails with LLM Provider NOT provided, or the call never shows up in Logs. What is wrong with the model string?

The first path segment of the model string is the provider switch, and only openai/ selects the OpenAI Chat Completions route that sends the request to api_base. Pass a bare catalog ID such as deepseek-v4.1-flash or grok-4.6 and nothing is sent: DSPy 3.3.1 raises dspy.LMInvalidRequestError wrapping litellm.BadRequestError: LLM Provider NOT provided. Pass in the LLM provider you are trying to call. You passed model=deepseek-v4.1-flash. Pass an ID whose own first segment is a LiteLLM provider name, such as anthropic/claude-sonnet-5, and it is handed to that provider's handler instead: the request becomes an Anthropic Messages call with the model cut down to claude-sonnet-5, so no Chat Completions request reaches the gateway; with api_base set to the /v1 URL, LiteLLM 1.101 posted it to a doubled /v1/v1/messages path in a local test, which is a 404. Keep openai/ as the first segment for every family: only that one prefix is removed and the rest is sent as the model field, which is why a GPT-family ID reads openai/openai/gpt-5.5. Then check the other two arguments. api_base must end in /v1 with nothing appended; the LiteLLM docs tie a Not Found error to a missing /v1 postfix, and DSPy 3.3.1 reports that 404 as dspy.LMUnsupportedModelError, a name that points at the model when the base URL is the problem. A 401 arrives as dspy.LMAuthError; check the value of ROUTER_ONE_API_KEY in the environment that runs the file. All of these subclass dspy.LMError, which exposes the HTTP status and, when the response carried one, the request_id.

I ran the same program twice and the second run left no rows in Logs. Did the requests get lost?

No. DSPy caches LM responses by default in two layers, an in-memory LRU cache and an on-disk cache under ~/.dspy_cache (DSPY_CACHEDIR moves it), and the cache tutorial states that both are enabled without any action. A request whose model string, messages, and parameters match an earlier one is answered locally: nothing is sent, so there is no trace and no charge, and with track_usage=True the usage for that call comes back empty. That is useful during development, since a repeated evaluation or compile costs nothing the second time, and confusing during reconciliation. To make every call reach the gateway, pass cache=False to dspy.LM, or disable both layers with dspy.configure_cache(enable_disk_cache=False, enable_memory_cache=False). To keep the cache but force one fresh sample, pass a new rollout_id together with a non-zero temperature; the dspy.LM reference notes that rollout_id is stripped before the request is sent. When you compare counts, compare Logs with the calls that were not cache hits, not with len(lm.history), which records cached calls too.

DSPy raises OpenAI's reasoning models require passing temperature=1.0 or None and max_tokens >= 16000 or None. Which Router One IDs trigger it?

That check runs inside dspy.LM before any request is sent, and it raises dspy.LMConfigurationError. DSPy lowercases the part of the model string after the last slash and matches it against a pattern for OpenAI reasoning names, so the openai/ prefixes do not matter: openai/openai/gpt-5.5 is checked as gpt-5.5. In DSPy 3.3.1, and in the 3.4.0b1 pre-release, the pattern covers o1, o3, o4, and o5 names and gpt-5 or gpt-5-something other than gpt-5-chat; a dotted name such as gpt-5.5 or gpt-5.6-sol does not match, so those IDs accept any temperature and max_tokens on the client side. The main branch widened the pattern on 2026-09-16 to dotted gpt-5 versions, so after a later upgrade the same IDs will match. When a name matches, dspy.LM raises the error above if temperature is set to a non-zero value other than 1.0 or max_tokens is set below 16000, and it sends max_completion_tokens in place of max_tokens. The portable setup is to leave both arguments unset, which is what the example does, or to pass temperature=1.0 and max_tokens=16000 for GPT-family IDs. IDs from other families, such as anthropic/claude-sonnet-5, deepseek-v4.1-flash, grok-4.6, or openai/gpt-6-astra, match neither version of the pattern.

Which models can DSPy use through the gateway?

Choose a current catalog model that supports both the endpoint and the features DSPy uses. Check /models and the model detail page for the exact ID, current rates, and capabilities; a family name such as GPT or Claude is not a compatibility guarantee. Seeing a model in the picker confirms discovery, so verify an actual request too.

Models are listed, but requests fail with 400 or 404. What should I check?

Record the actual request path and error message, then check the exact model ID. A 400 can indicate invalid parameters, unsupported tools, or a model/endpoint mismatch; a 404 can indicate an incorrect path or missing resource, so it does not by itself establish that a model was retired. If the error says must be called via, use the named endpoint or select a model supported on the current endpoint. Do not add or remove /v1 or /chat/completions across all clients indiscriminately.

Does this work from Mainland China?

Yes. The gateway is reachable from Mainland China without a VPN, and the configuration is identical to the global setup.

How do I debug a 401/402/403/429?

Match the request and error message in Dashboard → Logs. For 401, check whether the key was sent and is valid; for 402, check wallet balance and maxSpend; for 403, check key permissions and access restrictions. For 429, distinguish request/token limits from upstream throttling using the error details. Keep the request_id and follow the error-codes reference.