Skip to content

feat(llm): resolve provider-specific runtime metadata for routed models - #4423

Open
neubig wants to merge 2 commits into
mainfrom
feat/runtime-metadata-openrouter
Open

feat(llm): resolve provider-specific runtime metadata for routed models#4423
neubig wants to merge 2 commits into
mainfrom
feat/runtime-metadata-openrouter

Conversation

@neubig

@neubig neubig commented Aug 7, 2026

Copy link
Copy Markdown
Member

HUMAN:
Validated the runtime metadata changes with the listed unit and lint checks.

AGENT:

Why

The static model catalog (LiteLLM metadata) describes a model's nominal limits, which can overstate the limit of the route a configured provider actually serves. On OpenRouter, deepseek/deepseek-v4-flash-0731 advertises a 1M-token context, but the CoreWeave endpoint limits requests to 262k tokens. Because LiteLLM imports the model-level catalog value, LLM.effective_max_input_tokens reports the wrong context for routed models, feeding the wrong value into context validation, token-aware condensation, and telemetry.

See #4421.

Summary

  • Add LLM.resolve_runtime_metadata() / aresolve_runtime_metadata(): resolve route-aware limits lazily, cache with a TTL (positive and negative), deduplicate concurrent lookups, and fall back to model metadata on any error.
  • Add an OpenRouter adapter (openhands/sdk/llm/utils/providers/openrouter.py) that queries the per-endpoint catalog and applies litellm_extra_body.provider routing semantics (only/ignore/order/allow_fallbacks): exact for a pinned route, conservative lower bound when multiple eligible routes differ in context, None when routing is not safely interpretable.
  • Keep provider transport in a private module so it can be retired when LiteLLM gains equivalent provider-endpoint support.
  • effective_max_input_tokens now consults the cached route-aware value with no network I/O in the property, preserving explicit-max_input_tokens precedence and pre-resolution behavior.
  • Export ModelRuntimeMetadata from openhands.sdk.llm.

Issue Number

Closes #4421

How to Test

Unit tests use httpx.MockTransport against the real resolver/merge paths (real LLM construction, caching, precedence, routing semantics):

uv run pytest tests/sdk/llm/test_runtime_metadata.py -q          # 21 passed

Existing LLM coverage is unchanged (all pass):

uv run pytest tests/sdk/llm/test_llm.py tests/sdk/llm/test_llm_serialization.py \
  tests/sdk/llm/test_llm_litellm_extra_body.py tests/sdk/llm/test_model_info_proxy_lookup.py \
  tests/sdk/llm/test_llm_metrics.py tests/sdk/llm/test_llm_telemetry.py -q      # 194 passed

Lint/type/import checks via uv run --project . pre-commit run --files <changed files>: Ruff format, Ruff lint, pycodestyle, pyright, import-rules, and tool-registration all pass.

A live OpenRouter call is not required: the adapter's HTTP is exercised through httpx.MockTransport, and the parsing/routing logic is covered directly.


This pull request was created by an AI agent (OpenHands) on behalf of Graham Neubig.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:a8af737-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-a8af737-python \
  ghcr.io/openhands/agent-server:a8af737-python

All tags pushed for this build

ghcr.io/openhands/agent-server:a8af737-golang-amd64
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-golang-amd64
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-golang-amd64
ghcr.io/openhands/agent-server:a8af737-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:a8af737-golang-arm64
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-golang-arm64
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-golang-arm64
ghcr.io/openhands/agent-server:a8af737-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:a8af737-java-amd64
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-java-amd64
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-java-amd64
ghcr.io/openhands/agent-server:a8af737-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:a8af737-java-arm64
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-java-arm64
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-java-arm64
ghcr.io/openhands/agent-server:a8af737-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:a8af737-python-amd64
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-python-amd64
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-python-amd64
ghcr.io/openhands/agent-server:a8af737-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:a8af737-python-arm64
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-python-arm64
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-python-arm64
ghcr.io/openhands/agent-server:a8af737-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:a8af737-golang
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-golang
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-golang
ghcr.io/openhands/agent-server:a8af737-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:a8af737-java
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-java
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-java
ghcr.io/openhands/agent-server:a8af737-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:a8af737-python
ghcr.io/openhands/agent-server:a8af7375ea7972ba8a6d8987081aa704cf76de5a-python
ghcr.io/openhands/agent-server:feat-runtime-metadata-openrouter-python
ghcr.io/openhands/agent-server:a8af737-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., a8af737-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., a8af737-python-amd64) are also available if needed

Closes #4428

The static model catalog (LiteLLM metadata) describes a model's nominal
limits, which can overstate the limit of the route a configured provider
actually serves. On OpenRouter, for example, deepseek-v4-flash advertises
a 1M-token context while the CoreWeave endpoint limits requests to 262k.
This makes LLM.effective_max_input_tokens wrong for routed models and
feeds the wrong value into context management and telemetry.

Add LLM.resolve_runtime_metadata()/aresolve_runtime_metadata(), which
resolve route-aware limits lazily, cache them with a TTL (positive and
negative), deduplicate concurrent lookups, and always fall back to model
metadata on any error. The OpenRouter adapter queries the per-endpoint
catalog and applies `litellm_extra_body.provider` routing semantics
(only/ignore/order/allow_fallbacks): exact for a pinned route, a
conservative lower bound when multiple eligible routes differ, and None
when routing is not safely interpretable. Provider transport is confined
to a private module so it can be retired when LiteLLM gains equivalent
support. effective_max_input_tokens now consults the cached route-aware
value without any network I/O in the property, preserving explicit-
max_input_tokens precedence.

Closes: #4421

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/llm
   llm.py109310191%683, 699, 738–739, 744, 850, 866, 1049, 1089–1091, 1125, 1132, 1282, 1410, 1613, 1617–1618, 1727, 1731–1732, 1795, 1802, 1813, 1877, 1881–1882, 1954, 1961, 1971, 1978, 1989, 2039, 2052, 2070, 2084, 2086, 2088, 2113, 2115, 2124–2125, 2198, 2419, 2575–2576, 2673–2674, 2677–2680, 2682–2684, 2960–2961, 2970, 2988, 3015–3016, 3018, 3020, 3022, 3030, 3033, 3035, 3037, 3048–3049, 3057, 3060, 3063–3064, 3075–3077, 3081, 3085–3086, 3091, 3101, 3106, 3170, 3172, 3174–3177, 3179–3182, 3187–3190, 3205, 3216, 3276, 3278
openhands-sdk/openhands/sdk/llm/utils
   runtime_metadata.py861088%86–87, 162, 190, 196–197, 205–207, 209
openhands-sdk/openhands/sdk/llm/utils/providers
   openrouter.py109397%192, 217, 231
TOTAL397831118572% 

@all-hands-bot

Copy link
Copy Markdown
Collaborator

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

…e an event loop

resolve_provider_metadata_sync() called asyncio.run(), which raises
RuntimeError when the caller already owns a running event loop (e.g. the
agent-server async path). Detect that case up front and return None so the
caller falls back to model-level metadata instead of crashing, matching the
module's fallback-on-any-error contract.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig

neubig commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Pushed a65af7375e92a8d8987081aa704cf76de5a (a8af737) which resets the head SHA and clears the two stale Validate PR description check runs that were failing on the previous head. Full CI is now green on a8af737 (33 passing, 0 failed). The PR description's HUMAN: section was already updated to satisfy the validator. Requesting re-review.

This comment was created by an AI agent (OpenHands) on behalf of the automating user.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: a8af7375ea7972ba8a6d8987081aa704cf76de5a
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/2bc2888c-8f4c-4fd1-98cb-d8ddab7bab2b

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Overview

This PR adds provider-aware runtime metadata resolution for routed models (OpenRouter), addressing issue #4421 where the static LiteLLM catalog overstates context limits for specific endpoints. The design is clean: lazy resolution, TTL caching (positive + negative), concurrent-call deduplication, and graceful fallback to model metadata on any error. The OpenRouter adapter correctly implements only/ignore/order/allow_fallbacks routing semantics with conservative lower bounds when multiple eligible routes differ.

Risk Assessment: Medium

The code is well-structured and the fallback strategy is safe (any error → None → model metadata). However, there are two material gaps that prevent the PR from actually fixing the issue it claims to close, plus a thread-safety concern.

Material Findings

1. The feature is not wired up — the fix is inert (Correctness)

effective_max_input_tokens consults _runtime_metadata if it has been cached, but nothing in the codebase ever calls resolve_runtime_metadata() or aresolve_runtime_metadata(). The cache is never populated unless an external caller explicitly invokes these methods. This means effective_max_input_tokens will still return the model-level metadata value (the buggy behavior described in #4421) because _runtime_metadata stays None.

The PR description says "Closes #4421," but without a caller — e.g., in conversation startup, Agent.step(), or the condenser path — the bug is not actually fixed. At minimum, the agent-server or LocalConversation startup should call aresolve_runtime_metadata() before the first LLM completion. If the intent is to wire this up in a follow-up PR, the PR description should clarify that and not claim to close the issue.

2. effective_max_output_tokens does not consult runtime metadata (Consistency)

ModelRuntimeMetadata has a max_output_tokens field populated by the OpenRouter adapter, but effective_max_output_tokens (unchanged in this PR) does not consult it. For joint-budget providers, the output token limit feeds into _clamp_max_tokens_for_joint_budget() alongside effective_max_input_tokens. If the input context is route-aware but the output limit is not, the clamping math can be inconsistent. Either wire max_output_tokens into effective_max_output_tokens or remove the field from the model to avoid implying it is used.

3. Thread-safety of sync path instance mutation (Concurrency)

resolve_runtime_metadata() (sync) mutates _runtime_metadata, _runtime_metadata_fetched_at, and _runtime_metadata_negative_until without any lock. The async path has _inflight for deduplication, but the sync path has no dedup or locking. Since LLM is documented as an immutable Pydantic model that may be shared across conversations (and potentially threads), concurrent sync calls could race on these PrivateAttr fields. The worst case is duplicate upstream requests rather than corruption, but a lock or a note restricting the sync path to single-threaded use would be prudent.

Minor Notes

  • selected_provider for safe_lower_bound confidence: When allow_fallbacks=True and multiple eligible routes have different context limits, selected_provider is set to the first entry in order. But the router may use any eligible provider at runtime. Reporting a single selected_provider when any could be used is slightly misleading for telemetry. Consider setting it to None for safe_lower_bound.
  • Missing test for only routing positive case: There is a test for only returning None when no match, but no test verifying only matching and returning confidence="exact" with the correct endpoint. The logic is straightforward but worth covering.
  • detect_provider prefix check: model.startswith("openrouter") would match a hypothetical model like openrouterx/foo. Consider model.startswith("openrouter/") for precision.

What Looks Good

  • The fallback-on-any-error strategy (except ExceptionNone) is the right call for metadata discovery that must never block a completion.
  • The negative cache prevents hammering a flaky endpoint.
  • The force=True bypass for cache refresh is a clean escape hatch.
  • The sync-resolver-inside-running-loop guard is thoughtful.
  • Serialization correctly excludes runtime cache fields (test_serialization_excludes_runtime_cache).
  • The httpx.MockTransport test approach exercises real resolver/merge paths without live network calls.

)
if cached is not None and cached.max_input_tokens is not None:
return cached.max_input_tokens
return self._effective_max_input_tokens

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feature is not wired up. effective_max_input_tokens consults _runtime_metadata if cached, but no code anywhere in the codebase calls resolve_runtime_metadata() or aresolve_runtime_metadata(). The cache is never populated, so this property still returns the model-level metadata value — the bug from #4421 persists. A caller (e.g., conversation/agent startup) must invoke aresolve_runtime_metadata() before the first LLM completion for this fix to take effect. If wiring is deferred to a follow-up, the PR should not claim to close #4421.

return None

metadata = resolve_provider_metadata_sync(self)
self._store_runtime_metadata(metadata)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread-safety concern (sync path). resolve_runtime_metadata() mutates _runtime_metadata, _runtime_metadata_fetched_at, and _runtime_metadata_negative_until without a lock. The async path has _inflight for dedup, but the sync path has none. Since LLM is an immutable Pydantic model that may be shared across conversations/threads, concurrent sync calls could race on these PrivateAttr fields. Consider adding a lock or documenting that the sync path is single-threaded only.

max_output_tokens=min(completion_limits) if completion_limits else None,
source="openrouter_endpoints_api",
candidate_providers=provider_names,
selected_provider=reordered[0].get("provider_name"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selected_provider is misleading for safe_lower_bound. With allow_fallbacks=True and multiple eligible routes, the router may use any provider at runtime, but selected_provider is set to the first entry in order. Consider setting selected_provider=None when confidence="safe_lower_bound" to avoid implying a specific provider was selected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(llm): resolve provider-specific runtime metadata for routed models feat(llm): resolve provider-specific runtime metadata for routed models

2 participants