Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/models/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,58 @@ class ModelConfig:
# stating explicitly because "GLM-5.2 is multimodal" is an easy and
# costly assumption: it makes glm-5.2 look like a valid *vision* half
# for a fusion model, where it would fail on every image.
# OpenAI's open-weight models, served by several hosts in this registry
# (cerebras as ``gpt-oss-120b``, groq/baseten as ``openai/gpt-oss-120b``).
#
# These rows exist to STOP a prefix match, not merely to describe a model.
# ``get_model_config`` falls back to ``key.rsplit("-", 1)[0]``, under which
# "gpt-oss-120b" reduces to "gpt" and collided with the gpt-5.x family —
# so a bare gpt-oss id silently inherited gpt-5.5's 272k window, its 128k
# output cap and its $3/$15 pricing. That window sizes auto-compaction, so
# the session would run past the real limit and die on a context-length
# 400 rather than compacting. The namespaced ``openai/gpt-oss-120b`` did
# not start with "gpt" and so got the safe generic defaults instead: one
# model behaving two ways depending on which host served it.
#
# 131,072 both ways per OpenAI's model docs (2026-08-02). Hosts may cap
# output lower; the context window is the load-bearing number here.
"gpt-oss-120b": ModelConfig(
model_id="gpt-oss-120b",
display_name="GPT-OSS 120B",
context_window=131_072,
max_output_tokens=131_072,
supports_cache=False,
supports_vision=False,
),
"gpt-oss-20b": ModelConfig(
model_id="gpt-oss-20b",
display_name="GPT-OSS 20B",
context_window=131_072,
max_output_tokens=131_072,
supports_cache=False,
supports_vision=False,
),
# The namespaced forms groq and baseten actually serve. Explicit rows are
# the documented remedy for a vendor-qualified id needing a real window
# (see ``get_model_config``'s docstring and ``openai/gpt-5.6-luna``) —
# without them these fall to the generic 200k default, which is LARGER
# than the true 131k, so compaction is sized past the real limit.
"openai/gpt-oss-120b": ModelConfig(
model_id="openai/gpt-oss-120b",
display_name="GPT-OSS 120B",
context_window=131_072,
max_output_tokens=131_072,
supports_cache=False,
supports_vision=False,
),
"openai/gpt-oss-20b": ModelConfig(
model_id="openai/gpt-oss-20b",
display_name="GPT-OSS 20B",
context_window=131_072,
max_output_tokens=131_072,
supports_cache=False,
supports_vision=False,
),
"glm-5.2": ModelConfig(
model_id="glm-5.2",
display_name="GLM-5.2",
Expand Down
93 changes: 93 additions & 0 deletions src/providers/openai_compatible_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ class ProviderSpec:
#: churning hosted catalogs). None = static list only. See
#: src/providers/model_discovery.py (the discoveryService port).
dynamic_catalog: str | None = None
#: How a discovered list combines with ``available_models``:
#: ``"dynamic"`` (discovered REPLACES the static list) or ``"hybrid"``
#: (static first, discovered appended). Defaults to ``"dynamic"``, which
#: is right for a LOCAL server — sglang/vllm/ollama ship placeholder
#: model ids and the endpoint is the only truth. A hosted vendor is the
#: opposite: its curated list is deliberate, while its raw ``/models``
#: includes speech, moderation and embedding models that do not belong in
#: a coding agent's picker. ``openrouter_provider`` already passes
#: ``hybrid`` by hand for exactly this reason; this field lets a spec row
#: say the same thing.
catalog_mode: str = "dynamic"
#: Generated subclass name (for repr / debugging). Derived from ``id`` when
#: omitted.
class_name: str = ""
Expand Down Expand Up @@ -324,6 +335,87 @@ def resolved_class_name(self) -> str:
env_vars=("META_API_KEY", "META_AI_API_KEY"),
aliases=("meta-ai", "meta_ai", "muse", "muse-spark"),
),
# The four OpenAI-compatible vendors OpenCode ships that this table did
# not (reference_projects/opencode/packages/llm/src/providers/
# openai-compatible-profile.ts). Base URLs are OpenCode's, each confirmed
# live (2026-08-02) to answer /models with a 401/403 rather than a 404, so
# the host and path are current.
#
# Model ids could NOT come from that file: ``OpenAICompatibleProfile`` is
# ``{provider, baseURL}`` and nothing else, for all nine entries, and
# ``providers/xai.ts`` carries no ids either. They were read from each
# vendor's own docs on 2026-08-02 instead.
ProviderSpec(
id="groq",
dynamic_catalog="openai-compatible",
catalog_mode="hybrid",
label="Groq",
default_base_url="https://api.groq.com/openai/v1",
default_model="openai/gpt-oss-120b",
available_models=(
"openai/gpt-oss-120b",
"openai/gpt-oss-20b",
"llama-3.3-70b-versatile",
"llama-3.1-8b-instant",
),
env_vars=("GROQ_API_KEY",),
aliases=("groq-cloud", "groqcloud"),
),
ProviderSpec(
id="cerebras",
dynamic_catalog="openai-compatible",
catalog_mode="hybrid",
label="Cerebras",
default_base_url="https://api.cerebras.ai/v1",
default_model="gpt-oss-120b",
available_models=("gpt-oss-120b", "zai-glm-4.7"),
env_vars=("CEREBRAS_API_KEY",),
aliases=("cerebras-cloud", "cerebras_cloud"),
),
ProviderSpec(
id="baseten",
dynamic_catalog="openai-compatible",
catalog_mode="hybrid",
label="Baseten",
default_base_url="https://inference.baseten.co/v1",
default_model="deepseek-ai/DeepSeek-V4-Pro",
available_models=(
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V4-Flash-0731",
"moonshotai/Kimi-K3",
"zai-org/GLM-5.2",
"openai/gpt-oss-120b",
),
env_vars=("BASETEN_API_KEY",),
aliases=("base-ten", "base_ten"),
),
# xAI also exposes an OpenAI *Responses* endpoint, and OpenCode defaults
# its xai facade to that protocol (providers/xai.ts: `model: responses`).
# This row deliberately takes the Chat Completions route instead, for a
# structural reason rather than only an absence of evidence:
# ``openai_responses`` is imported solely by ``openai_provider``, and
# ``_use_responses`` sits behind ``_is_first_party_base_url()``, which
# #783 scoped to api.openai.com. Routing xai over Responses is therefore
# not a row change at all — it needs a hand-written class plus a carve-out
# in that host gate.
#
# Chat Completions is also known-good for this model rather than merely
# assumed: OpenCode's own docs serve grok-4.5 over BOTH protocols, routing
# it to /chat/completions via @ai-sdk/openai-compatible in
# packages/web/src/content/docs/go.mdx and to /responses via @ai-sdk/openai
# in zen.mdx. A row is the right shape for the former; the latter would be
# a separate change, made by someone who can probe it with a real key.
ProviderSpec(
id="xai",
dynamic_catalog="openai-compatible",
catalog_mode="hybrid",
label="xAI (Grok)",
default_base_url="https://api.x.ai/v1",
default_model="grok-4.5",
available_models=("grok-4.5", "grok-4.3"),
env_vars=("XAI_API_KEY", "GROK_API_KEY"),
aliases=("x-ai", "x_ai", "grok"),
),
)


Expand Down Expand Up @@ -398,6 +490,7 @@ def get_available_models(self) -> list[str]:
getattr(self, "api_key", None) or None,
spec.dynamic_catalog,
spec.available_models,
mode=spec.catalog_mode,
)


Expand Down
23 changes: 21 additions & 2 deletions tests/test_git_utilities.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import subprocess
from unittest import mock
import tempfile
import pytest

Expand Down Expand Up @@ -55,8 +56,26 @@ def test_run_git_failure(self):
assert rc != 0

def test_run_git_timeout(self):
stdout, stderr, rc = _run_git(["log"], timeout=0.001)
assert rc != 0 or stdout == ""
"""A timed-out git call reports failure rather than partial output.

Driven by a forced ``TimeoutExpired`` instead of a 1ms budget on a
real ``git log``. The old form asserted ``rc != 0 or stdout == ""``
after a genuine 0.001s timeout, which is a race: when the runner is
fast enough for git to finish inside the millisecond, the call
succeeds with real output and the assertion fails. It flaked on CI
(fail / pass / fail across three runs of an unrelated change) while
testing nothing on the runs where it passed for the wrong reason —
a timeout that never fired exercises none of this branch.
"""
with mock.patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd=["git", "log"], timeout=0.001),
):
stdout, stderr, rc = _run_git(["log"], timeout=0.001)

assert rc == -1
assert stdout == ""
assert stderr == "Command timed out"


class TestGetRepoRoot:
Expand Down
Loading
Loading