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
5 changes: 5 additions & 0 deletions configs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ environment credentials. Native bearer tokens use the `Authorization` header.
Base URLs also accept `OPENAI_BASE_URL` or `ANTHROPIC_BASE_URL` for the
selected provider after `OPENCOLLAB_BASE_URL`.

Logical model names and request capacity shared by independent Team or Workflow
processes are configured through [shared model routing](../docs/model-routing.md).
The guide covers local backend URLs, API credentials, same-host request pools,
and common gateways for multiple hosts.

## Wire protocol

`OPENCOLLAB_WIRE_PROTOCOL=chat_completions` is the default for OpenAI-compatible
Expand Down
94 changes: 94 additions & 0 deletions docs/model-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Shared model routing and request capacity

[Chinese](model-routing.zh-CN.md)

A logical model name selects an explicit backend through `model_routes`. Each
route supplies the physical model identifier, provider, endpoint and credentials.
The runtime resolves the route before selecting model capabilities, reasoning
controls, token estimators and context limits. Requests and provider usage records
keep the physical model identifier. `OpenCollab.configuration` exposes the logical
to physical mapping alongside provider, protocol, context and capacity metadata.

```python
import os
from pathlib import Path

from opencollab import OpenCollab

pool = Path(os.environ["MODEL_REQUEST_POOL"]).resolve()
pool.mkdir(parents=True, exist_ok=True)

client = OpenCollab(
".",
model="worker",
model_routes={
"worker": {
"model": "served-model-id",
"provider": "openai",
"wire_protocol": "chat_completions",
"base_url": os.environ["MODEL_BASE_URL"],
"api_key_env": "MODEL_API_KEY", # pragma: allowlist secret
"context_window": 131072,
"request_pool": str(pool),
"request_capacity": 2,
},
},
)
```

The same mapping is accepted through `config={"model_routes": ...}`. CLI and SDK
configuration also accept `OPENCOLLAB_MODEL_ROUTES` as a JSON object in the process
environment or a [configuration env file](../configs/README.md). SDK constructor
arguments take precedence. A Team role's `model` selects either a logical route or
an ordinary physical model. Names absent from `model_routes` retain the existing
default provider configuration. A route resolves once to its declared physical
model, including when that identifier is also another logical name.

`provider` defaults to `openai` and `wire_protocol` to `chat_completions`. Native
Anthropic routes use `provider="anthropic"`. Route credentials follow the existing
provider and endpoint credential selection rules. `api_key_env` selects a specific
secret from the environment or selected env file and raises an error if unset.
An explicit route `api_key` takes precedence. `auth_token` supports native
Anthropic bearer authentication. Each route owns its endpoint credentials.
`OpenCollab(...)` resolves env-file credentials at construction time. Explicit
route `context_window` values apply to the physical model. Sampling, thinking,
retry, timeout and output settings continue through the ordinary provider path.
Responses requests retain their response items, provider identity and optional
`response_session_id` handling.

`request_pool` and `request_capacity` are supplied together. The pool is an
existing absolute local directory shared by all participating processes. The
runtime verifies the directory before acquiring a request slot. Its slot files
hold OS locks until native completion or cancellation cleanup finishes. Queued
cancellation releases only the waiter's resources. A terminated process releases
its OS locks. Admission covers provider retries and releases before tools execute,
so a parent waiting for a child leaves request capacity available to that child.
The pool may be configured at the top level with `OPENCOLLAB_REQUEST_POOL` and
`OPENCOLLAB_REQUEST_CAPACITY`, or the corresponding SDK configuration fields.
A route-specific pool pair overrides that shared default. Multiple routes can
share one pool when their requests consume the same backend capacity.

The pool's `capacity` file records its configured size. Conflicting capacities
raise an error because independent processes using different slot counts can
oversubscribe the same backend. OS locks serialize that configuration decision.
Keep the directory and its lock files in place while any participating process
is running. To change capacity, stop those processes, empty the pool directory,
then restart them with the new value. A local filesystem with working OS locks is
required. Network filesystem locking and machines with separate filesystems
require admission at the backend or gateway.

A container must mount the same host directory at the configured absolute path.
A missing directory produces an error explaining the mount requirement. Containers
with independently created directories have independent capacity, even when the
path strings match. Pool paths belong to the process running the Python model
client, which may differ from its tools' execution environment.

For multiple hosts, configure every route's `base_url` to the same externally
managed gateway and configure its backend request capacity there. The gateway
receives the route's physical model identifier through the existing provider wire
protocol and owns admission across hosts. Gateway protocol support must cover
the selected Chat Completions, Responses or Anthropic Messages path and preserve
model identity, reasoning controls and continuation data. Backend work that
continues after an HTTP disconnect is governed by the backend or gateway's
cancellation policy. Local request pools coordinate client request lifetimes on
one host.
46 changes: 46 additions & 0 deletions docs/model-routing.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 共享模型路由与请求容量

[English](model-routing.md)

`model_routes` 将逻辑模型名映射到明确配置的后端。每条路由包含实际模型标识、服务商、端点和凭据。运行时先解析路由,再选择模型能力、推理参数、token 估算方式和上下文限制。请求及服务商用量记录保留实际模型标识。`OpenCollab.configuration` 提供逻辑名与实际模型的对应关系,以及服务商、协议、上下文和容量信息。

```python
import os
from pathlib import Path

from opencollab import OpenCollab

pool = Path(os.environ["MODEL_REQUEST_POOL"]).resolve()
pool.mkdir(parents=True, exist_ok=True)

client = OpenCollab(
".",
model="worker",
model_routes={
"worker": {
"model": "served-model-id",
"provider": "openai",
"wire_protocol": "chat_completions",
"base_url": os.environ["MODEL_BASE_URL"],
"api_key_env": "MODEL_API_KEY", # pragma: allowlist secret
"context_window": 131072,
"request_pool": str(pool),
"request_capacity": 2,
},
},
)
```

同一映射也可以通过 `config={"model_routes": ...}` 传入。CLI 和 SDK 均支持将 JSON 对象写入进程环境或[配置文件](../configs/README.md)中的 `OPENCOLLAB_MODEL_ROUTES`。SDK 构造参数优先。Team 角色的 `model` 可以选择逻辑路由,也可以填写普通模型标识。未出现在 `model_routes` 中的名称沿用已有的默认服务商配置。路由只解析一次,并使用其声明的实际模型,即使该标识同时也是另一个逻辑名称。

`provider` 默认为 `openai`,`wire_protocol` 默认为 `chat_completions`。Anthropic 原生路由使用 `provider="anthropic"`。路由凭据遵循已有的服务商和端点选择规则。`api_key_env` 指定从环境或所选配置文件中读取的密钥变量,变量缺失时会报错。路由中明确提供的 `api_key` 优先。`auth_token` 支持 Anthropic 原生 bearer 认证。每条路由持有自己的端点凭据。`OpenCollab(...)` 在构造时完成环境文件凭据解析。路由中的 `context_window` 设置作用于实际模型。采样、思考、重试、超时和输出设置继续经过现有服务商实现。Responses 请求保留响应条目、服务商身份信息和可选的 `response_session_id` 行为。

`request_pool` 和 `request_capacity` 需要成对提供。请求池使用已经存在的本地绝对路径目录,所有参与进程共享这个目录。运行时在取得请求名额前验证目录。每个名额通过文件上的操作系统锁持有,直到原生请求完成或取消清理结束。取消排队中的请求只释放该等待者自己的资源。进程结束时,操作系统会释放其文件锁。请求名额覆盖服务商重试,并在执行工具前释放,因此父任务等待子任务期间,子任务可以使用模型请求容量。

顶层 `OPENCOLLAB_REQUEST_POOL` 和 `OPENCOLLAB_REQUEST_CAPACITY`,以及对应的 SDK 配置字段,可以设置共用请求池。路由中成对提供的池配置优先。当多个路由消耗同一个后端的容量时,它们可以共享一个池。

池目录的 `capacity` 文件记录配置的容量。独立进程如果使用不同的名额数,就可能超过后端容量,因此冲突配置会报错。操作系统锁保证这些配置检查按顺序进行。参与进程运行期间,需要保留池目录及其锁文件。调整容量时,先停止这些进程,清空池目录,再使用新值启动。请求池依赖支持操作系统锁的本地文件系统。网络文件系统的锁语义,以及文件系统彼此独立的多台机器,需要由后端或网关管理请求容量。

容器需要将同一个主机目录挂载到配置中的绝对路径。目录缺失时,错误信息会提示挂载要求。各自创建的独立目录拥有独立容量,即使它们的路径字符串相同。池路径属于运行 Python 模型客户端的进程,其所在环境可能与工具执行环境不同。

多台主机共享容量时,将各条路由的 `base_url` 配置为同一个外部网关,并在网关中设置后端请求容量。网关通过已有服务商协议接收实际模型标识,负责跨主机分配名额。网关需要支持所选的 Chat Completions、Responses 或 Anthropic Messages 路径,并保留模型身份、推理参数和续接数据。HTTP 断开后仍在执行的后端工作由后端或网关的取消策略管理。本地请求池协调一台主机上的客户端请求生命周期。
1 change: 1 addition & 0 deletions opencollab/adapters/cli/config_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from rich.console import Console

from opencollab.bootstrap.config import accepted_api_key_envs, missing_api_key
from opencollab.bootstrap.config import resolve_model_route as resolve_model_route


def missing_api_key_for(
Expand Down
20 changes: 16 additions & 4 deletions opencollab/adapters/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
missing_api_key_for,
print_missing_key_hint,
resolve_config,
resolve_model_route,
)
from opencollab.adapters.cli.live_prompt import LivePrompt
from opencollab.adapters.cli.team import build_team_app
Expand Down Expand Up @@ -114,8 +115,11 @@ def main_callback(
raise typer.BadParameter("--hold requires --prompt or --prompt-file.")

cfg = resolve_config(workspace, model, provider, api_key, base_url, budget)
if missing_api_key_for(cfg["provider"], cfg["api_key"], cfg["base_url"], cfg.get("auth_token")):
print_missing_key_hint(console, cfg["provider"], cfg["base_url"])
model_cfg = _initial_model_config(cfg, team_config)
if missing_api_key_for(
model_cfg["provider"], model_cfg["api_key"], model_cfg["base_url"], model_cfg.get("auth_token"),
):
print_missing_key_hint(console, model_cfg["provider"], model_cfg["base_url"])
raise typer.Exit(code=1)

run_with_bounded_shutdown(
Expand All @@ -133,6 +137,13 @@ def main_callback(
)
)

def _initial_model_config(cfg: dict[str, Any], team_config_path: str | None) -> dict[str, Any]:
role_model = None
if cfg.get("model_routes"):
team = load_team_config(path=team_config_path)
role_model = team.role_for(team.entry).model
return resolve_model_route(cfg, role_model)

def _resolve_one_shot_prompt(prompt: str | None, prompt_file: str | None) -> str | None:
return resolve_text_input(
prompt,
Expand Down Expand Up @@ -338,9 +349,10 @@ async def _run(
# and lines are read straight from stdin.
has_screen = sys.stdin.isatty() and sys.stdout.isatty()
prompt = LivePrompt(tui, console=console, interactive=has_screen)
model_cfg = _initial_model_config(cfg, team_config_path)
tui.print_welcome(
model=cfg["model"],
provider=cfg["provider"],
model=model_cfg["model"],
provider=model_cfg["provider"],
workspace=workspace,
budget=cfg.get("budget"),
interactive=one_shot_prompt is None or (hold_after_run and has_screen),
Expand Down
8 changes: 6 additions & 2 deletions opencollab/adapters/cli/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
missing_api_key_for,
print_missing_key_hint,
resolve_config,
resolve_model_route,
)
from opencollab.adapters.cli.text_input import resolve_text_input
from opencollab.application.async_timeout import run_with_bounded_shutdown
Expand Down Expand Up @@ -145,8 +146,11 @@ def run_cmd(
parsed_args["goal"] = task_text

cfg = resolve_config(workspace, model, provider, api_key, base_url, budget)
if missing_api_key_for(cfg["provider"], cfg["api_key"], cfg["base_url"], cfg.get("auth_token")):
print_missing_key_hint(console, cfg["provider"], cfg["base_url"])
model_cfg = resolve_model_route(cfg)
if missing_api_key_for(
model_cfg["provider"], model_cfg["api_key"], model_cfg["base_url"], model_cfg.get("auth_token"),
):
print_missing_key_hint(console, model_cfg["provider"], model_cfg["base_url"])
raise typer.Exit(code=1)

# Fan-out workflows raise the fallback budget. Leave an explicit budget
Expand Down
Loading
Loading