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
10 changes: 10 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,16 @@ CI covers Python 3.9/3.12, PoW WASM, shell/JavaScript and isolated browser fixtu
Locally: `python -m unittest discover -s tests -t . -v`.
[Real Harness CLI fixtures with mocked providers](docs/HARNESS.en.md#offline-verification).

### Model identifiers

`GET /v1/models` lists bare IDs and `provider/model` aliases, for example
`qwen3.8-max` and `qwen/qwen3.8-max`, or `deepseek-chat` and
`deepseek/deepseek-chat`. Both forms work with the API and Harness catalog.
The prefix must match the provider and does not enable disabled models.
DeepSeek aliases share the same history binding and continuation accounting:
changing the spelling neither creates a new chat nor resets replay protection.
Verified offline; this does not verify model access for a live account.

### Harness contract: history and stopping

Before contacting any provider, the bridge validates tool-call history: every
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,16 @@ CI проверяет Python 3.9/3.12, PoW WASM, shell/JavaScript и изоли
сценарии. Локально: `python -m unittest discover -s tests -t . -v`.
[Сценарии настоящего Harness CLI с подменённым провайдером](docs/HARNESS.ru.md#проверка-без-аккаунтов).

### Идентификаторы моделей

`GET /v1/models` показывает короткие имена и алиасы `провайдер/модель`: например,
`qwen3.8-max` и `qwen/qwen3.8-max`, `deepseek-chat` и `deepseek/deepseek-chat`.
Оба варианта принимаются API и каталогом Harness. Префикс должен соответствовать
провайдеру; он не включает отключённую модель. Алиасы DeepSeek используют одну
привязку истории и один учёт продолжения: смена написания не создаёт новый чат
и не сбрасывает защиту от повторов. Проверено офлайн; это не проверка доступа
к моделям аккаунта.

### Контракт Harness: история и остановка

Перед запросом к любому провайдеру мост проверяет историю tool calls: каждому
Expand Down
16 changes: 12 additions & 4 deletions integrations/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,26 +65,34 @@ def discover(base_url: str, without_browser=False) -> list:

def catalog_models(catalog: dict, without_browser=False) -> list:
rows = catalog.get("data") if isinstance(catalog, dict) else None
if not isinstance(rows, list) or not rows or len(rows) > 100:
# Bare ids plus owner/id aliases roughly double the catalog size.
if not isinstance(rows, list) or not rows or len(rows) > 200:
raise ValueError("Bridge advertises no models or an invalid catalog; enable a provider first")
models, seen = [], set()
for row in rows:
if not isinstance(row, dict):
raise ValueError("Invalid bridge model entry")
model = row.get("id")
owner = row.get("owned_by")
if (not isinstance(model, str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}", model)
# Bare ids and matching `owner/id` aliases from the bridge catalog.
if (not isinstance(model, str)
or not re.fullmatch(
r"(?:[a-z][a-z0-9-]{0,31}/)?[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}", model)
or model in seen or not isinstance(owner, str) or owner not in PROVIDERS):
raise ValueError("Invalid or duplicate bridge model identity")
bare = model.split("/", 1)[-1]
if "/" in model and not model.startswith(owner + "/"):
raise ValueError("Invalid or duplicate bridge model identity")
seen.add(model)
models.append({"id": model, "name": LABELS.get(model, model + " · bridge"),
models.append({"id": model, "name": LABELS.get(bare, LABELS.get(model, model + " · bridge")),
"input": ["text"], "reasoningEfforts": False,
"contextWindow": 32768, "maxTokens": 4096})
# Validate the entire advertised catalog before excluding any provider.
if without_browser:
models = [model for row, model in zip(rows, models)
if row["owned_by"] in BROWSERLESS_PROVIDERS
or (row['owned_by'] == 'kimi' and row['id'] in KIMI_PUBLIC_MODELS)]
or (row["owned_by"] == "kimi"
and row["id"].split("/", 1)[-1] in KIMI_PUBLIC_MODELS)]
if not models:
raise ValueError("Bridge advertises no browserless models for --without-browser")
return models
Expand Down
5 changes: 3 additions & 2 deletions server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
SERVER_INTERACTIVE_LOGIN,
SESSION_REFRESH_ENABLED,
SESSION_REFRESH_INTERVAL,
advertised_model_ids,
is_known_model,
resolve_model_type,
model_provider,
Expand Down Expand Up @@ -650,7 +651,7 @@ def list_models():
"object": "list",
"data": [
{"id": name, "object": "model", "created": created, "owned_by": model_provider(name)}
for name in MODEL_MAP
for name in advertised_model_ids()
],
}

Expand Down Expand Up @@ -701,7 +702,7 @@ async def _chat_completions(req: ChatCompletionRequest):
if not is_known_model(req.model):
return _error(
f"The model `{req.model}` does not exist. Available models: "
f"{', '.join(MODEL_MAP)}",
f"{', '.join(advertised_model_ids())}",
status=404, err_type="model_not_found",
)

Expand Down
38 changes: 35 additions & 3 deletions server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,52 @@
OPTIONAL_MODEL_PROVIDERS[name] = "gemini"


def model_provider(name: str) -> str:
def _bare_owner(name: str) -> str:
return OPTIONAL_MODEL_PROVIDERS.get(name, "qwen" if name in QWEN_MODEL_MAP else "deepseek")


def canonical_model(name: str):
"""Bare public id for a known model, or None.

Accepts both `qwen3.8-omni-flash` and `qwen/qwen3.8-omni-flash` when the
author segment matches the model's provider.
"""
if name in MODEL_MAP:
return name
if "/" not in name:
return None
owner, _, bare = name.partition("/")
if "/" in bare or bare not in MODEL_MAP or _bare_owner(bare) != owner:
return None
return bare


def model_provider(name: str) -> str:
bare = canonical_model(name)
return _bare_owner(bare if bare is not None else name)


DEFAULT_MODEL = "qwen3.8-omni-flash"


def is_known_model(name: str) -> bool:
"""Whether `name` is a model id we accept (used to 404 unknown models)."""
return name in MODEL_MAP
return canonical_model(name) is not None


def resolve_model_type(name: str) -> str:
"""Translate a public model id to the provider's wire model value.

Caller must check `is_known_model` first; this raises KeyError otherwise.
"""
return MODEL_MAP[name]
bare = canonical_model(name)
if bare is None:
raise KeyError(name)
return MODEL_MAP[bare]


def advertised_model_ids():
"""Bare ids plus matching `owner/id` aliases for OpenAI-compatible clients."""
for name in MODEL_MAP:
yield name
yield f"{_bare_owner(name)}/{name}"
6 changes: 5 additions & 1 deletion server/conversation_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pathlib import Path

from settings import ROOT
from .config import canonical_model
from .schemas import ChatMessage
from .openai_format import _text_of
from .recovery_status import RECOVERY_STOP_TEXT
Expand Down Expand Up @@ -40,7 +41,10 @@ def normalized(message):


def digest(req, messages):
payload = [req.model, req.user, req.thinking, req.search, req.tools,
# Owner-prefixed aliases (deepseek/deepseek-expert) must share history with
# the bare public id used when the link was stored.
model = canonical_model(req.model) or req.model
payload = [model, req.user, req.thinking, req.search, req.tools,
[normalized(m) for m in messages]]
return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False,
separators=(',', ':')).encode()).hexdigest()
Expand Down
14 changes: 14 additions & 0 deletions tests/test_conversation_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ def test_restart_reuses_chat_and_only_new_messages_once(self):
self.assertNotIn('fixture result', self.cache.path.read_bytes().decode(errors='ignore'))
self.assertEqual(self.cache.path.stat().st_mode & 0o777, 0o600)

def test_owner_prefixed_model_alias_reuses_the_same_history(self):
self.cache.remember(self.req, self.reply, '', self.calls, 0)
req = self.req.model_copy(update={
'model': 'deepseek/deepseek-chat', 'messages': self.history(),
})
continuation, steps = self.cache.prepare(req)
self.assertEqual(continuation.conversation_id, 'owned:2')
self.assertEqual(steps, 1)
# A different bare model still must not share the link.
with self.assertRaises(ConversationStateError):
self.cache.prepare(self.req.model_copy(update={
'model': 'deepseek/deepseek-expert', 'messages': self.history(),
}))

def test_unknown_changed_scope_and_edited_histories_never_start_chat(self):
self.cache.remember(self.req, self.reply, '', self.calls, 0)
for update in ({'model': 'deepseek-expert'}, {'user': 'other'}, {'tools': []},
Expand Down
17 changes: 17 additions & 0 deletions tests/test_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ def test_without_browser_keeps_only_the_explicit_direct_kimi_models(self):
self.assertEqual([m['id'] for m in models], ['kimi-instant', 'kimi-k3', 'kimi-k2.8'])
self.assertEqual(models[0]['name'], 'Kimi Instant · web')

def test_catalog_accepts_matching_owner_prefixed_aliases(self):
catalog = {"data": [
{"id": "qwen3.8-omni-flash", "owned_by": "qwen"},
{"id": "qwen/qwen3.8-omni-flash", "owned_by": "qwen"},
{"id": "kimi/kimi-instant", "owned_by": "kimi"},
]}
models = harness.catalog_models(catalog)
self.assertEqual([model["id"] for model in models],
["qwen3.8-omni-flash", "qwen/qwen3.8-omni-flash", "kimi/kimi-instant"])
self.assertEqual(models[1]["name"], "Qwen3.8 Omni Flash · web")
self.assertEqual(
[model["id"] for model in harness.catalog_models(catalog, without_browser=True)],
["qwen3.8-omni-flash", "qwen/qwen3.8-omni-flash", "kimi/kimi-instant"],
)
with self.assertRaises(ValueError):
harness.catalog_models({"data": [{"id": "evil/qwen3.8-omni-flash", "owned_by": "qwen"}]})

def run_cli_catalog(self, catalog, *args):
requests = []
real_client = httpx.Client
Expand Down
16 changes: 16 additions & 0 deletions tests/test_qwen_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,24 @@ async def test_models_have_correct_provider_ownership(self):
response = await self.http.get("/v1/models")
models = {m["id"]: m["owned_by"] for m in response.json()["data"]}
self.assertEqual(models["qwen3.8-omni-flash"], "qwen")
self.assertEqual(models["qwen/qwen3.8-omni-flash"], "qwen")
self.assertEqual(models["qwen3.8-max"], "qwen")
self.assertEqual(models["qwen/qwen3.8-max"], "qwen")
self.assertEqual(models["deepseek-chat"], "deepseek")
self.assertEqual(models["deepseek/deepseek-chat"], "deepseek")

async def test_author_prefixed_model_ids_are_accepted(self):
fake = Mock()
fake.chat.return_value = Reply("QWEN_OK", CID)
with patch.object(api, "get_qwen_client", return_value=fake), patch.object(api, "get_client") as ds:
response = await self.http.post(
"/v1/chat/completions",
json={**self.body, "model": "qwen/qwen3.8-max"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["model"], "qwen/qwen3.8-max")
self.assertEqual(fake.chat.call_args.args[2], "qwen3.8-max")
ds.assert_not_called()

async def test_qwen_requests_do_not_use_deepseek_credentials(self):
fake = Mock()
Expand Down