Add local host mode and configurable LLM providers
This commit is contained in:
@@ -60,6 +60,26 @@ operator view for the tasks that actually execute on that Host, including
|
|||||||
per-step screenshots, OCR observations, and UI-tree results. The Cloud Console
|
per-step screenshots, OCR observations, and UI-tree results. The Cloud Console
|
||||||
remains the fleet-level view for dispatch status and Cloud-proxy planner history.
|
remains the fleet-level view for dispatch status and Cloud-proxy planner history.
|
||||||
|
|
||||||
|
## Local-only Host Agent
|
||||||
|
|
||||||
|
Run without a Cloud Control Plane by setting `HOST_AGENT_MODE=local`. Tasks
|
||||||
|
submitted in the Host console are queued and executed in the same process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HOST_AGENT_MODE=local
|
||||||
|
export AI_PLANNER_ENABLED=true
|
||||||
|
export AI_PLANNER_PROVIDER=openai-compatible
|
||||||
|
export AI_PLANNER_MODEL=qwen2.5
|
||||||
|
export AI_PLANNER_API_KEY=local-key
|
||||||
|
export AI_PLANNER_BASE_URL=http://127.0.0.1:11434/v1
|
||||||
|
uv run --package device-host-agent device-host-agent setup
|
||||||
|
uv run --package device-host-agent device-host-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
`openai-compatible` works with Ollama, LM Studio, vLLM, or another server that
|
||||||
|
implements OpenAI `/chat/completions`. Hosted `openai` and `anthropic` providers
|
||||||
|
also accept `AI_PLANNER_API_KEY` and their conventional API key variables.
|
||||||
|
|
||||||
## Project Direction
|
## Project Direction
|
||||||
|
|
||||||
The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture
|
The durable roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md). The architecture
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ from host_agent.identity import HostIdentityStore
|
|||||||
from host_agent.instance_lock import InstanceLock
|
from host_agent.instance_lock import InstanceLock
|
||||||
from host_agent.lease import ActiveAssignmentRunner
|
from host_agent.lease import ActiveAssignmentRunner
|
||||||
from host_agent.local_account import LocalAccountStore
|
from host_agent.local_account import LocalAccountStore
|
||||||
|
from host_agent.local_client import LocalHostAgentClient
|
||||||
from host_agent.mcp_lock import McpBusyTracker
|
from host_agent.mcp_lock import McpBusyTracker
|
||||||
from host_agent.mcp_token import McpTokenStore
|
from host_agent.mcp_token import McpTokenStore
|
||||||
from host_agent.policy_cache import HostPolicyCacheStore
|
from host_agent.policy_cache import HostPolicyCacheStore
|
||||||
@@ -173,25 +174,37 @@ def create_application(
|
|||||||
startup_config.identity_path
|
startup_config.identity_path
|
||||||
)
|
)
|
||||||
owned_enrollment_client = enrollment_client is None
|
owned_enrollment_client = enrollment_client is None
|
||||||
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(
|
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
|
||||||
startup_config
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
resolved_config = resolve_host_identity(
|
if startup_config.mode == "local":
|
||||||
startup_config,
|
resolved_config = replace(
|
||||||
identity_store=resolved_identity_store,
|
startup_config, control_plane_url="", host_id="local-host"
|
||||||
client=bootstrap_client,
|
)
|
||||||
)
|
resolved_manager = manager or _configured_device_manager(
|
||||||
bootstrap_client.config = resolved_config
|
config_store, config=resolved_config, enrollment_client=None
|
||||||
resolved_manager = manager or _configured_device_manager(
|
)
|
||||||
config_store,
|
else:
|
||||||
config=resolved_config,
|
resolved_config = resolve_host_identity(
|
||||||
enrollment_client=bootstrap_client,
|
startup_config,
|
||||||
)
|
identity_store=resolved_identity_store,
|
||||||
|
client=bootstrap_client,
|
||||||
|
)
|
||||||
|
bootstrap_client.config = resolved_config
|
||||||
|
resolved_manager = manager or _configured_device_manager(
|
||||||
|
config_store,
|
||||||
|
config=resolved_config,
|
||||||
|
enrollment_client=bootstrap_client,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if owned_enrollment_client:
|
if owned_enrollment_client:
|
||||||
bootstrap_client.close()
|
bootstrap_client.close()
|
||||||
client = HostAgentClient(resolved_config)
|
if resolved_config.mode == "local":
|
||||||
|
client = LocalHostAgentClient(
|
||||||
|
host_id="local-host",
|
||||||
|
device_ids=lambda: [device.id for device in resolved_manager.list_devices()],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
client = HostAgentClient(resolved_config)
|
||||||
|
|
||||||
history_store = ConsoleHistoryStore(
|
history_store = ConsoleHistoryStore(
|
||||||
resolved_config.identity_path.parent / "host_console_history.sqlite3",
|
resolved_config.identity_path.parent / "host_console_history.sqlite3",
|
||||||
@@ -352,7 +365,7 @@ def _configured_device_manager(
|
|||||||
config_store: DeviceConfigStore,
|
config_store: DeviceConfigStore,
|
||||||
*,
|
*,
|
||||||
config: HostAgentConfig,
|
config: HostAgentConfig,
|
||||||
enrollment_client: HostAgentEnrollmentClient,
|
enrollment_client: HostAgentEnrollmentClient | None,
|
||||||
) -> DeviceManager:
|
) -> DeviceManager:
|
||||||
manager = DeviceManager()
|
manager = DeviceManager()
|
||||||
for device in config_store.list():
|
for device in config_store.list():
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ class LocalAccountSetupError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> None:
|
def main(argv: Sequence[str] | None = None) -> None:
|
||||||
|
_load_dotenv()
|
||||||
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
|
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
subparsers.add_parser("setup", help="Create the local operator account")
|
subparsers.add_parser("setup", help="Create the local operator account")
|
||||||
@@ -96,3 +99,19 @@ def _prompt_and_create(store: LocalAccountStore):
|
|||||||
if password != confirm:
|
if password != confirm:
|
||||||
raise LocalAccountSetupError("passwords do not match")
|
raise LocalAccountSetupError("passwords do not match")
|
||||||
return store.create(username, password)
|
return store.create(username, password)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
"""Load a simple repository-root .env without overriding shell values."""
|
||||||
|
path = Path.cwd() / ".env"
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
name, value = line.split("=", 1)
|
||||||
|
name = name.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if name and name not in os.environ:
|
||||||
|
os.environ[name] = value
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class HostAgentConfigurationError(ValueError):
|
|||||||
|
|
||||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||||
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
|
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
|
||||||
|
_HOST_AGENT_MODES = frozenset({"cloud", "local"})
|
||||||
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
|
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
|
||||||
"HOST_AGENT_RUNTIME_SUPERVISED",
|
"HOST_AGENT_RUNTIME_SUPERVISED",
|
||||||
"HOST_AGENT_RUNTIME_HOST",
|
"HOST_AGENT_RUNTIME_HOST",
|
||||||
@@ -22,7 +23,8 @@ _REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class HostAgentConfig:
|
class HostAgentConfig:
|
||||||
control_plane_url: str
|
control_plane_url: str = ""
|
||||||
|
mode: str = "cloud"
|
||||||
host_id: str = ""
|
host_id: str = ""
|
||||||
token: str = field(default="", repr=False)
|
token: str = field(default="", repr=False)
|
||||||
identity_path: Path = Path("tasks/host_identity.json")
|
identity_path: Path = Path("tasks/host_identity.json")
|
||||||
@@ -57,16 +59,19 @@ def load_host_agent_config(
|
|||||||
) -> HostAgentConfig:
|
) -> HostAgentConfig:
|
||||||
values = os.environ if env is None else env
|
values = os.environ if env is None else env
|
||||||
_reject_removed_runtime_supervision_settings(values)
|
_reject_removed_runtime_supervision_settings(values)
|
||||||
|
mode = values.get("HOST_AGENT_MODE", "cloud").strip().lower()
|
||||||
|
if mode not in _HOST_AGENT_MODES:
|
||||||
|
raise HostAgentConfigurationError("HOST_AGENT_MODE must be cloud or local")
|
||||||
control_plane_url = (
|
control_plane_url = (
|
||||||
values.get(
|
values.get(
|
||||||
"HOST_AGENT_CONTROL_PLANE_URL",
|
"HOST_AGENT_CONTROL_PLANE_URL",
|
||||||
"https://amcp.home.jerryyan.top",
|
"https://amcp.home.jerryyan.top" if mode == "cloud" else "",
|
||||||
)
|
)
|
||||||
.strip()
|
.strip()
|
||||||
.rstrip("/")
|
.rstrip("/")
|
||||||
)
|
)
|
||||||
parsed_url = urlparse(control_plane_url)
|
parsed_url = urlparse(control_plane_url)
|
||||||
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
if mode == "cloud" and (parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc):
|
||||||
raise HostAgentConfigurationError(
|
raise HostAgentConfigurationError(
|
||||||
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
|
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
|
||||||
)
|
)
|
||||||
@@ -82,9 +87,10 @@ def load_host_agent_config(
|
|||||||
|
|
||||||
config = HostAgentConfig(
|
config = HostAgentConfig(
|
||||||
control_plane_url=control_plane_url,
|
control_plane_url=control_plane_url,
|
||||||
|
mode=mode,
|
||||||
identity_path=identity_path,
|
identity_path=identity_path,
|
||||||
local_account_path=local_account_path,
|
local_account_path=local_account_path,
|
||||||
enrollment_managed=True,
|
enrollment_managed=mode == "cloud",
|
||||||
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
|
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
|
||||||
heartbeat_interval_seconds=_positive_float(
|
heartbeat_interval_seconds=_positive_float(
|
||||||
values,
|
values,
|
||||||
@@ -128,9 +134,7 @@ def load_host_agent_config(
|
|||||||
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
|
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
|
||||||
200,
|
200,
|
||||||
),
|
),
|
||||||
ai_planner_transport=_parse_ai_planner_transport(
|
ai_planner_transport=("direct" if mode == "local" else _parse_ai_planner_transport(values.get("AI_PLANNER_TRANSPORT"))),
|
||||||
values.get("AI_PLANNER_TRANSPORT")
|
|
||||||
),
|
|
||||||
dependency_supervisor_enabled=_truthy(
|
dependency_supervisor_enabled=_truthy(
|
||||||
values, "HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED", False
|
values, "HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED", False
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from cloud.internal_api.models import (
|
||||||
|
ClaimResponse,
|
||||||
|
HeartbeatResponse,
|
||||||
|
HostTaskCancellationResponse,
|
||||||
|
HostTaskSubmissionResponse,
|
||||||
|
LeaseRenewalResponse,
|
||||||
|
TerminalResultResponse,
|
||||||
|
)
|
||||||
|
from host_agent.progress import TaskProgressSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
class LocalHostAgentClient:
|
||||||
|
"""In-process task broker used when the Host Agent runs without Cloud."""
|
||||||
|
|
||||||
|
def __init__(self, *, host_id: str, device_ids: Callable[[], list[str]]) -> None:
|
||||||
|
self.host_id = host_id
|
||||||
|
self._device_ids = device_ids
|
||||||
|
self._queue: asyncio.Queue[tuple[str, str, str | None]] = asyncio.Queue()
|
||||||
|
self._cancelled: set[str] = set()
|
||||||
|
|
||||||
|
async def submit_self_task(self, *, goal: str, device_id: str | None = None) -> HostTaskSubmissionResponse:
|
||||||
|
task_id = f"local-{uuid.uuid4().hex}"
|
||||||
|
await self._queue.put((task_id, goal, device_id))
|
||||||
|
return HostTaskSubmissionResponse(task_id=task_id)
|
||||||
|
|
||||||
|
async def claim(self):
|
||||||
|
task_id, goal, requested_device = await self._queue.get()
|
||||||
|
devices = self._device_ids()
|
||||||
|
device_id = requested_device or (devices[0] if devices else "")
|
||||||
|
if not device_id:
|
||||||
|
return None
|
||||||
|
from cloud.internal_api.models import AssignmentModel
|
||||||
|
|
||||||
|
return AssignmentModel(
|
||||||
|
task_id=task_id,
|
||||||
|
attempt=1,
|
||||||
|
lease_id=f"local-lease-{uuid.uuid4().hex}",
|
||||||
|
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
|
||||||
|
host_id=self.host_id,
|
||||||
|
device_id=device_id,
|
||||||
|
goal=goal,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heartbeat(self, *args, **kwargs) -> HeartbeatResponse:
|
||||||
|
return HeartbeatResponse(
|
||||||
|
host_id=self.host_id,
|
||||||
|
accepted_devices=len(self._device_ids()),
|
||||||
|
received_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def renew(self, assignment, *, progress: TaskProgressSnapshot | None = None) -> LeaseRenewalResponse:
|
||||||
|
return LeaseRenewalResponse(
|
||||||
|
status="renewed",
|
||||||
|
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def report_result(self, assignment, *, status: str, failure_reason: str | None = None, result: dict | None = None) -> TerminalResultResponse:
|
||||||
|
return TerminalResultResponse(status="recorded")
|
||||||
|
|
||||||
|
async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse:
|
||||||
|
self._cancelled.add(task_id)
|
||||||
|
return HostTaskCancellationResponse(task_id=task_id, status="cancel_requested")
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
return None
|
||||||
@@ -27,6 +27,15 @@ def test_load_host_agent_config_allows_explicit_direct_planner_transport() -> No
|
|||||||
assert config.ai_planner_transport == "direct"
|
assert config.ai_planner_transport == "direct"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_host_agent_config_supports_local_mode() -> None:
|
||||||
|
config = load_host_agent_config({"HOST_AGENT_MODE": "local"})
|
||||||
|
|
||||||
|
assert config.mode == "local"
|
||||||
|
assert config.control_plane_url == ""
|
||||||
|
assert config.enrollment_managed is False
|
||||||
|
assert config.ai_planner_transport == "direct"
|
||||||
|
|
||||||
|
|
||||||
def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
|
def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
|
||||||
config = load_host_agent_config(
|
config = load_host_agent_config(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ DEFAULT_PROVIDER = "anthropic"
|
|||||||
DEFAULT_MODEL_BY_PROVIDER = {
|
DEFAULT_MODEL_BY_PROVIDER = {
|
||||||
"anthropic": "claude-sonnet-5",
|
"anthropic": "claude-sonnet-5",
|
||||||
"openai": "gpt-5.6",
|
"openai": "gpt-5.6",
|
||||||
|
"openai_compatible": "local-model",
|
||||||
}
|
}
|
||||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ PROVIDER_ENV = "AI_PLANNER_PROVIDER"
|
|||||||
MODEL_ENV = "AI_PLANNER_MODEL"
|
MODEL_ENV = "AI_PLANNER_MODEL"
|
||||||
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
|
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
|
||||||
THINKING_BUDGET_ENV = "AI_PLANNER_THINKING_BUDGET_TOKENS"
|
THINKING_BUDGET_ENV = "AI_PLANNER_THINKING_BUDGET_TOKENS"
|
||||||
|
API_KEY_ENV = "AI_PLANNER_API_KEY"
|
||||||
|
BASE_URL_ENV = "AI_PLANNER_BASE_URL"
|
||||||
|
|
||||||
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
|
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
|
||||||
|
|
||||||
@@ -27,6 +30,8 @@ class PlannerConfig:
|
|||||||
model: str = ""
|
model: str = ""
|
||||||
timeout: float = DEFAULT_TIMEOUT_SECONDS
|
timeout: float = DEFAULT_TIMEOUT_SECONDS
|
||||||
thinking_budget_tokens: int | None = None
|
thinking_budget_tokens: int | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
|
||||||
def resolved_model(self) -> str:
|
def resolved_model(self) -> str:
|
||||||
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider]
|
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider]
|
||||||
@@ -40,6 +45,8 @@ def load_config(env: Mapping[str, str] | None = None) -> PlannerConfig:
|
|||||||
model=values.get(MODEL_ENV) or "",
|
model=values.get(MODEL_ENV) or "",
|
||||||
timeout=_parse_timeout(values.get(TIMEOUT_ENV)),
|
timeout=_parse_timeout(values.get(TIMEOUT_ENV)),
|
||||||
thinking_budget_tokens=_parse_thinking_budget(values.get(THINKING_BUDGET_ENV)),
|
thinking_budget_tokens=_parse_thinking_budget(values.get(THINKING_BUDGET_ENV)),
|
||||||
|
api_key=values.get(API_KEY_ENV) or _provider_key(values),
|
||||||
|
base_url=values.get(BASE_URL_ENV) or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +60,8 @@ def _parse_provider(value: str | None) -> str:
|
|||||||
if value is None:
|
if value is None:
|
||||||
return DEFAULT_PROVIDER
|
return DEFAULT_PROVIDER
|
||||||
provider = value.strip().lower()
|
provider = value.strip().lower()
|
||||||
|
if provider in {"openai-compatible", "openai_compatible", "local"}:
|
||||||
|
return "openai_compatible"
|
||||||
return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER
|
return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER
|
||||||
|
|
||||||
|
|
||||||
@@ -74,3 +83,12 @@ def _parse_thinking_budget(value: str | None) -> int | None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
return budget if budget > 0 else None
|
return budget if budget > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_key(values: Mapping[str, str]) -> str | None:
|
||||||
|
provider = (values.get(PROVIDER_ENV) or DEFAULT_PROVIDER).strip().lower()
|
||||||
|
if provider == "openai":
|
||||||
|
return values.get("OPENAI_API_KEY") or None
|
||||||
|
if provider == "anthropic":
|
||||||
|
return values.get("ANTHROPIC_API_KEY") or None
|
||||||
|
return None
|
||||||
|
|||||||
@@ -288,10 +288,14 @@ class OpenAIToolCallingClient:
|
|||||||
|
|
||||||
def build_client(config: PlannerConfig) -> ToolCallingClient:
|
def build_client(config: PlannerConfig) -> ToolCallingClient:
|
||||||
model = config.resolved_model()
|
model = config.resolved_model()
|
||||||
if config.provider == "openai":
|
if config.provider in {"openai", "openai_compatible"}:
|
||||||
return OpenAIToolCallingClient(model=model)
|
return OpenAIToolCallingClient(
|
||||||
|
model=model, api_key=config.api_key, base_url=config.base_url
|
||||||
|
)
|
||||||
return AnthropicToolCallingClient(
|
return AnthropicToolCallingClient(
|
||||||
model=model,
|
model=model,
|
||||||
|
api_key=config.api_key,
|
||||||
|
base_url=config.base_url,
|
||||||
thinking_budget_tokens=config.thinking_budget_tokens,
|
thinking_budget_tokens=config.thinking_budget_tokens,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user