From 34046457634103e321313b1a9890bc7e1ba7b042 Mon Sep 17 00:00:00 2001 From: Karolis Date: Fri, 9 Oct 2026 13:57:10 +0400 Subject: [PATCH 1/3] feat(cli): add `lightning code` to set up OpenCode for code.lightning.ai `lightning code setup opencode` adds code.lightning.ai as the `lightning` provider in OpenCode's global config and creates an API key that bills the org the user picks (--org, or a numbered prompt in a terminal). Pro, Teams and Enterprise orgs are accepted; a Free org gets a link to upgrade instead. The user's config is never rewritten: the provider block is spliced into the JSONC file so comments and other settings stay byte-for-byte, and the key goes into OpenCode's auth.json (0600) rather than the config file. Re-runs reuse the key, switching org or --rotate-key replaces and revokes it, and --dry-run shows the diff. Also adds `lightning code status`, `lightning code remove opencode` (removes our entries and revokes the key) and `lightning code token` for other tools. --- python/docs/cli/code.rst | 8 + python/docs/cli/index.rst | 2 + python/examples/README.md | 1 + python/examples/code/opencode-auth.json | 13 + python/examples/code/opencode.json | 59 ++++ python/examples/code_cli.rst | 87 +++++ python/lightning_sdk/cli/code/__init__.py | 16 + python/lightning_sdk/cli/code/account.py | 180 ++++++++++ python/lightning_sdk/cli/code/models.py | 28 ++ python/lightning_sdk/cli/code/opencode.py | 290 ++++++++++++++++ python/lightning_sdk/cli/code/remove.py | 46 +++ python/lightning_sdk/cli/code/setup.py | 112 +++++++ python/lightning_sdk/cli/code/status.py | 55 +++ python/lightning_sdk/cli/code/token.py | 47 +++ python/lightning_sdk/cli/entrypoint.py | 3 + python/lightning_sdk/cli/groups.py | 7 + python/lightning_sdk/utils/jsonc.py | 321 ++++++++++++++++++ python/tests/cli/code/__init__.py | 0 python/tests/cli/code/test_code.py | 388 ++++++++++++++++++++++ python/tests/utils/test_jsonc.py | 100 ++++++ 20 files changed, 1763 insertions(+) create mode 100644 python/docs/cli/code.rst create mode 100644 python/examples/code/opencode-auth.json create mode 100644 python/examples/code/opencode.json create mode 100644 python/examples/code_cli.rst create mode 100644 python/lightning_sdk/cli/code/__init__.py create mode 100644 python/lightning_sdk/cli/code/account.py create mode 100644 python/lightning_sdk/cli/code/models.py create mode 100644 python/lightning_sdk/cli/code/opencode.py create mode 100644 python/lightning_sdk/cli/code/remove.py create mode 100644 python/lightning_sdk/cli/code/setup.py create mode 100644 python/lightning_sdk/cli/code/status.py create mode 100644 python/lightning_sdk/cli/code/token.py create mode 100644 python/lightning_sdk/utils/jsonc.py create mode 100644 python/tests/cli/code/__init__.py create mode 100644 python/tests/cli/code/test_code.py create mode 100644 python/tests/utils/test_jsonc.py diff --git a/python/docs/cli/code.rst b/python/docs/cli/code.rst new file mode 100644 index 000000000..e60850a37 --- /dev/null +++ b/python/docs/cli/code.rst @@ -0,0 +1,8 @@ +Code +++++ + +.. lightning-reference:: code + :root-label: lightning code + :anchor-prefix: code + + from lightning_sdk.cli.groups import code diff --git a/python/docs/cli/index.rst b/python/docs/cli/index.rst index c8fc6c8df..c1c8aeacb 100644 --- a/python/docs/cli/index.rst +++ b/python/docs/cli/index.rst @@ -98,6 +98,7 @@ Common Workflows * Configure accounts, organizations, teamspaces, cloud accounts, and SSH with :doc:`config`, :doc:`api-key`, and :doc:`ssh`. * Manage lower-level sandbox sessions with :doc:`sandbox`. +* Point coding agents such as OpenCode at code.lightning.ai with :doc:`code`. Command details --------------- @@ -124,3 +125,4 @@ reference. base-studio license cp + code diff --git a/python/examples/README.md b/python/examples/README.md index 95cdadf42..905513cb0 100644 --- a/python/examples/README.md +++ b/python/examples/README.md @@ -71,6 +71,7 @@ export LIGHTNING_SANDBOX_API_KEY="..." | [`sandboxes_cli.rst`](sandboxes_cli.rst) | Create sandboxes, run commands, inspect logs, stop, resume, and delete from the CLI | | [`logs_cli.rst`](logs_cli.rst) | Tail, follow, search, and page logs for jobs, MMTs, deployments, and sandboxes | | [`api_cli.rst`](api_cli.rst) | Call authenticated Lightning API endpoints directly from shell scripts | +| [`code_cli.rst`](code_cli.rst) | Set up OpenCode for code.lightning.ai and create keys for other coding tools | # Running examples diff --git a/python/examples/code/opencode-auth.json b/python/examples/code/opencode-auth.json new file mode 100644 index 000000000..290f0afec --- /dev/null +++ b/python/examples/code/opencode-auth.json @@ -0,0 +1,13 @@ +{ + "lightning": { + "type": "api", + "key": "sk-lit-replace-me", + "metadata": { + "managed_by": "lightning-sdk", + "org_id": "01jexample0rg0000000000000", + "org_name": "my-org", + "key_id": "01jexamplekey0000000000000", + "key_name": "opencode on my-laptop 2026-10-09" + } + } +} diff --git a/python/examples/code/opencode.json b/python/examples/code/opencode.json new file mode 100644 index 000000000..32d03c80a --- /dev/null +++ b/python/examples/code/opencode.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "lightning": { + "npm": "@ai-sdk/openai-compatible", + "name": "Lightning AI", + "options": { + "baseURL": "https://code.lightning.ai/v1", + "timeout": 600000 + }, + "models": { + "glm-5.3": { + "id": "lightning-ai/glm-5.3", + "name": "GLM-5.3", + "reasoning": true, + "tool_call": true, + "interleaved": "reasoning_content", + "limit": { + "context": 1048576, + "output": 32768 + } + }, + "glm-5.3-flash": { + "id": "lightning-ai/glm-5.3-flash", + "name": "GLM-5.3 Flash", + "reasoning": true, + "tool_call": true, + "interleaved": "reasoning_content", + "attachment": true, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 253952, + "output": 32768 + } + }, + "deepseek-v4.1-flash": { + "id": "lightning-ai/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "reasoning": true, + "tool_call": true, + "interleaved": "reasoning_content", + "limit": { + "context": 253952, + "output": 32768 + } + } + } + } + }, + "model": "lightning/glm-5.3" +} diff --git a/python/examples/code_cli.rst b/python/examples/code_cli.rst new file mode 100644 index 000000000..86712c2a1 --- /dev/null +++ b/python/examples/code_cli.rst @@ -0,0 +1,87 @@ +Coding agents CLI examples +========================== + +`code.lightning.ai `_ serves GLM-5.3, GLM-5.3 Flash and +DeepSeek V4.1 Flash to coding agents through an OpenAI-compatible API at +``https://code.lightning.ai/v1``. The ``lightning code`` commands set up a coding +tool to use it and create the API key it needs. + +Requests bill the organization you pick, which needs a Pro, Teams or Enterprise +plan. A Free organization gets a link to upgrade instead of a key. + +Set up OpenCode +--------------- + +.. code-block:: console + + $ pip install lightning-sdk -U + $ lightning login + $ curl -fsSL https://opencode.ai/install | bash + + $ lightning code setup opencode --org my-org + OpenCode now uses code.lightning.ai, billed to My Org [my-org]. + Provider: lightning in ~/.config/opencode/opencode.json + API key: opencode on my-laptop 2026-10-09 in ~/.local/share/opencode/auth.json + Default: lightning/glm-5.3 + + $ opencode + +Without ``--org``, the command lists your organizations with their plans and asks +which one pays. In scripts and other non-interactive shells, ``--org`` is required. + +The setup leaves the rest of OpenCode's config alone: + +- The ``lightning`` provider is added to your global config, keeping its comments + and other settings. The file is backed up as ``opencode.json.lightning-backup`` + first. `code/opencode.json `_ is what a new config looks like. +- The API key goes to OpenCode's credential store, ``auth.json``, next to the keys + ``opencode auth login`` saves, never into the config file. Its metadata records + the organization and key, as in `code/opencode-auth.json `_. +- ``lightning/glm-5.3`` becomes the default model only if you have no default yet. + Pass ``--model`` to set one anyway. + +Preview the changes first, or pick a different default model: + +.. code-block:: console + + $ lightning code setup opencode --org my-org --dry-run + $ lightning code setup opencode --org my-org --model glm-5.3-flash + +Run it again to switch organizations, which creates a key there and revokes the +old one, or to pick up new models. ``--rotate-key`` replaces the key in place. + +Check and undo the setup +------------------------ + +.. code-block:: console + + $ lightning code status + OpenCode + Org: my-org + API key: opencode on my-laptop 2026-10-09 (01jexamplekey0000000000000) + Config: ~/.config/opencode/opencode.json + Default: lightning/glm-5.3 + + $ lightning code remove opencode + +``remove`` takes out the provider, a default model on it, and the key, +which it also revokes. Pass ``--keep-key`` to leave the key active. + +Use other tools +--------------- + +``lightning code token`` creates a key for anything else that speaks the OpenAI +or Anthropic API. The key is printed once, so store it straight away. + +.. code-block:: console + + $ export OPENAI_BASE_URL=https://code.lightning.ai/v1 + $ export OPENAI_API_KEY=$(lightning code token --org my-org) + + $ export ANTHROPIC_BASE_URL=https://code.lightning.ai + $ export ANTHROPIC_AUTH_TOKEN=$(lightning code token --org my-org --name claude-code) + $ claude --model glm-5.3 + + $ lightning code token --org my-org --json + +Keys created this way are listed and deleted with ``lightning api-key``. diff --git a/python/lightning_sdk/cli/code/__init__.py b/python/lightning_sdk/cli/code/__init__.py new file mode 100644 index 000000000..78ff31d26 --- /dev/null +++ b/python/lightning_sdk/cli/code/__init__.py @@ -0,0 +1,16 @@ +"""code.lightning.ai CLI commands.""" + +import rich_click as click + + +def register_commands(group: click.Group) -> None: + """Register code.lightning.ai commands with the given group.""" + from lightning_sdk.cli.code.remove import remove + from lightning_sdk.cli.code.setup import setup + from lightning_sdk.cli.code.status import status + from lightning_sdk.cli.code.token import token + + group.add_command(setup) + group.add_command(status) + group.add_command(remove) + group.add_command(token) diff --git a/python/lightning_sdk/cli/code/account.py b/python/lightning_sdk/cli/code/account.py new file mode 100644 index 000000000..a06d37f69 --- /dev/null +++ b/python/lightning_sdk/cli/code/account.py @@ -0,0 +1,180 @@ +"""Org, plan and API key handling shared by the `lightning code` commands.""" + +import socket +import sys +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import date +from typing import Optional + +import rich_click as click + +from lightning_sdk.api.api_key_api import ApiKeyApi +from lightning_sdk.api.utils import _get_cloud_url, cached_lightning_client +from lightning_sdk.lightning_cloud.openapi import V1APIKey, V1Organization + +# The plan names GetBillingSubscription reports, by tier. A lapsed subscription reads as Free. +_TIERS = {"free": "free", "pro": "pro", "professional": "pro", "teams": "teams", "enterprise": "enterprise"} + + +@dataclass +class CodingOrg: + """An org the user can bill coding tools to, with its plan.""" + + id: str + name: str + display_name: str + personal: bool + coding_disabled: bool + # None when the plan can't be read, e.g. a member without billing access + plan: Optional[str] + + @property + def tier(self) -> Optional[str]: + if self.plan is None: + return None + return _TIERS.get(self.plan.lower(), "other") + + @property + def label(self) -> str: + title = self.display_name or self.name + if self.personal: + title = f"{title} (personal)" + if title.lower() != self.name.lower() and not self.personal: + title = f"{title} [{self.name}]" + return title + + @property + def plan_label(self) -> str: + if self.coding_disabled: + return "coding tools turned off" + if self.plan is None: + return "plan unknown" + if self.tier == "free": + return "Free, upgrade needed" + return self.plan + + +def _plan_name(org_id: str) -> Optional[str]: + try: + return cached_lightning_client().billing_service_get_billing_subscription(org_id=org_id).name or "Free" + except Exception: + return None + + +def _coding_org(org: V1Organization, plan: Optional[str]) -> CodingOrg: + return CodingOrg( + id=org.id, + name=org.name or "", + display_name=org.display_name or "", + personal=bool(org.is_personal_org), + coding_disabled=bool(org.disable_coding_agents), + plan=plan, + ) + + +def list_coding_orgs(*, with_plans: bool = True) -> list[CodingOrg]: + """Return the user's orgs, the personal org first, with their plans unless ``with_plans`` is off.""" + orgs = cached_lightning_client().organizations_service_list_organizations().organizations or [] + coding_orgs = [_coding_org(org, None) for org in orgs] + if with_plans: + load_plans(coding_orgs) + return sorted(coding_orgs, key=lambda org: (not org.personal, (org.display_name or org.name).lower())) + + +def load_plans(orgs: list[CodingOrg]) -> None: + with ThreadPoolExecutor(max_workers=8) as pool: + futures = [pool.submit(_plan_name, org.id) for org in orgs] + for org, future in zip(orgs, futures): + org.plan = future.result() + + +def _find_org(orgs: list[CodingOrg], name: str) -> CodingOrg: + wanted = name.lower() + for org in orgs: + if wanted in (org.name.lower(), org.id.lower()): + return org + matches = [org for org in orgs if org.display_name.lower() == wanted] + if len(matches) == 1: + return matches[0] + names = ", ".join(org.name for org in orgs) + raise click.UsageError(f"You aren't a member of an organization named '{name}'. Yours: {names}") + + +def _interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def choose_org(org_name: Optional[str], *, previous_org_id: Optional[str] = None) -> CodingOrg: + """Pick the org to bill: ``--org`` when given, otherwise ask, or use the only one there is.""" + orgs = list_coding_orgs(with_plans=False) + if not orgs: + raise click.ClickException("Your account isn't a member of any organization.") + if org_name or len(orgs) == 1: + org = _find_org(orgs, org_name) if org_name else orgs[0] + load_plans([org]) + return org + if not _interactive(): + names = ", ".join(org.name for org in orgs) + raise click.UsageError(f"Choose the organization to bill with --org. Yours: {names}") + + load_plans(orgs) + + click.echo("Which organization should pay for code.lightning.ai?") + width = len(str(len(orgs))) + for number, org in enumerate(orgs, start=1): + click.echo(f" {number:>{width}}. {org.label} ({org.plan_label})") + default = next((i for i, org in enumerate(orgs, start=1) if org.id == previous_org_id), None) + if default is None: + default = next((i for i, org in enumerate(orgs, start=1) if org.tier not in (None, "free")), 1) + number = click.prompt("Organization", type=click.IntRange(1, len(orgs)), default=default) + return orgs[number - 1] + + +def upgrade_url(org: CodingOrg) -> str: + return f"{_get_cloud_url()}/me/settings/coding-usage?coding_org={org.id}" + + +def require_coding_plan(org: CodingOrg) -> None: + """Stop unless ``org``'s plan includes code.lightning.ai: Pro, Teams or Enterprise.""" + if org.coding_disabled: + raise click.ClickException( + f"Coding tools are turned off for {org.label}. Ask an org admin to turn them on, or pick another org." + ) + if org.tier == "free": + raise click.ClickException( + f"{org.label} is on the Free plan, which doesn't include code.lightning.ai.\n" + f"Upgrade to Pro or Teams to use it: {upgrade_url(org)}\n" + f"Compare plans: {_get_cloud_url()}/pricing" + ) + if org.tier is None: + click.echo(f"Couldn't read the plan for {org.label}; continuing. Requests fail if it doesn't include coding.") + + +def default_key_name(tool: str) -> str: + host = socket.gethostname().split(".")[0] or "unknown host" + return f"{tool} on {host} {date.today().isoformat()}" + + +def create_key(org: CodingOrg, name: str, description: str) -> V1APIKey: + """Create a Member-role key that bills ``org``, as the Coding Usage settings page does.""" + key = ApiKeyApi().create(org.id, name, description=description) + if not key.raw_key: + raise click.ClickException("The API key was created but its secret wasn't returned.") + return key + + +def key_exists(org_id: str, key_id: str) -> bool: + try: + return any(key.id == key_id for key in ApiKeyApi().list(org_id)) + except Exception: + return False + + +def revoke_key(org_id: str, key_id: str) -> bool: + """Delete a key, returning whether it worked. A key someone already deleted counts as revoked.""" + try: + ApiKeyApi().delete(org_id, key_id) + return True + except Exception: + return not key_exists(org_id, key_id) diff --git a/python/lightning_sdk/cli/code/models.py b/python/lightning_sdk/cli/code/models.py new file mode 100644 index 000000000..1b3dfe116 --- /dev/null +++ b/python/lightning_sdk/cli/code/models.py @@ -0,0 +1,28 @@ +"""The models code.lightning.ai serves, as coding tools need to be told about them.""" + +from dataclasses import dataclass + +CODE_BASE_URL = "https://code.lightning.ai/v1" +# OpenAI-compatible tools wait this long for a response before giving up +REQUEST_TIMEOUT_MS = 600_000 +OUTPUT_TOKENS = 32_768 + + +@dataclass(frozen=True) +class CodingModel: + # the short name tool configs use for the model + key: str + id: str + name: str + context_window: int + images: bool = False + + +# Keep in sync with lightning-ui's CodingUsage/harnesses.ts, which shows the same configs on the web. +CODING_MODELS = ( + CodingModel("glm-5.3", "lightning-ai/glm-5.3", "GLM-5.3", 1_048_576), + CodingModel("glm-5.3-flash", "lightning-ai/glm-5.3-flash", "GLM-5.3 Flash", 253_952, images=True), + CodingModel("deepseek-v4.1-flash", "lightning-ai/deepseek-v4.1-flash", "DeepSeek V4.1 Flash", 253_952), +) +DEFAULT_MODEL = CODING_MODELS[0].key +MODEL_KEYS = tuple(model.key for model in CODING_MODELS) diff --git a/python/lightning_sdk/cli/code/opencode.py b/python/lightning_sdk/cli/code/opencode.py new file mode 100644 index 000000000..64a7172a1 --- /dev/null +++ b/python/lightning_sdk/cli/code/opencode.py @@ -0,0 +1,290 @@ +"""OpenCode: add code.lightning.ai as the `lightning` provider without touching the rest of its config. + +The provider block goes into the user's global config, spliced in so comments and other +settings stay as they are. The API key goes into OpenCode's own credential store, +auth.json, where `opencode auth login` keeps keys; OpenCode hands it to the provider of the +same id. Its metadata records which org and key `lightning code` set up, for re-runs, +`lightning code status` and `lightning code remove`. +""" + +import difflib +import json +import os +import shutil +import stat +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +from lightning_sdk.cli.code.models import CODE_BASE_URL, CODING_MODELS, OUTPUT_TOKENS, REQUEST_TIMEOUT_MS +from lightning_sdk.utils import jsonc + +NAME = "OpenCode" +PROVIDER_ID = "lightning" +SCHEMA_URL = "https://opencode.ai/config.json" +INSTALL_COMMAND = "curl -fsSL https://opencode.ai/install | bash" +# marks the auth.json entries `lightning code` wrote, as opposed to ones a user added +MANAGED_BY = "lightning-sdk" +BACKUP_SUFFIX = ".lightning-backup" + +# Read in this order, later files winning; see OpenCode's config/config.ts. +_GLOBAL_CONFIG_FILES = ("config.json", "opencode.json", "opencode.jsonc") +_ENV_OVERRIDES = { + "OPENCODE_CONFIG": "loads another config file on top of the global one", + "OPENCODE_CONFIG_CONTENT": "puts inline config on top of the global one", + "OPENCODE_AUTH_CONTENT": "replaces auth.json, so the key written there isn't used", +} + + +def _xdg_dir(variable: str, fallback: str) -> Path: + # OpenCode uses xdg-basedir, which falls back to these paths on every platform, Windows included + return Path(os.environ.get(variable) or Path.home() / fallback) / "opencode" + + +def config_dir() -> Path: + return _xdg_dir("XDG_CONFIG_HOME", ".config") + + +def auth_path() -> Path: + return _xdg_dir("XDG_DATA_HOME", ".local/share") / "auth.json" + + +def config_path() -> Path: + """The global config file to edit: the one that wins when several exist, else a new opencode.json.""" + for name in reversed(_GLOBAL_CONFIG_FILES): + path = config_dir() / name + if path.exists(): + return path + return config_dir() / "opencode.json" + + +def provider_config() -> dict[str, Any]: + """The `provider.lightning` block. It has no apiKey: OpenCode reads that from auth.json.""" + models = {} + for model in CODING_MODELS: + entry: dict[str, Any] = { + "id": model.id, + "name": model.name, + "reasoning": True, + "tool_call": True, + "interleaved": "reasoning_content", + } + if model.images: + entry["attachment"] = True + entry["modalities"] = {"input": ["text", "image"], "output": ["text"]} + entry["limit"] = {"context": model.context_window, "output": OUTPUT_TOKENS} + models[model.key] = entry + return { + "npm": "@ai-sdk/openai-compatible", + "name": "Lightning AI", + "options": {"baseURL": CODE_BASE_URL, "timeout": REQUEST_TIMEOUT_MS}, + "models": models, + } + + +def model_ref(model_key: str) -> str: + return f"{PROVIDER_ID}/{model_key}" + + +def _read(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + +def _write(path: Path, text: str, *, mode: Optional[int] = None) -> None: + """Replace ``path`` atomically, keeping its permissions unless ``mode`` is given.""" + path.parent.mkdir(parents=True, exist_ok=True) + if mode is None: + mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644 + tmp = path.with_name(f".{path.name}.lightning-tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + os.chmod(tmp, mode) + os.replace(tmp, path) + + +@dataclass +class Credential: + """The `lightning` entry in auth.json.""" + + key: str + metadata: dict[str, str] + + @property + def managed(self) -> bool: + return self.metadata.get("managed_by") == MANAGED_BY + + @property + def org_id(self) -> Optional[str]: + return self.metadata.get("org_id") + + @property + def key_id(self) -> Optional[str]: + return self.metadata.get("key_id") + + +@dataclass +class State: + """What OpenCode's files say about the Lightning setup right now.""" + + config_path: Path + config_text: str + auth: dict[str, Any] + credential: Optional[Credential] + + @property + def provider(self) -> Optional[Any]: + return jsonc.get_value(self.config_text, ["provider", PROVIDER_ID]) + + @property + def model(self) -> Optional[str]: + return jsonc.get_value(self.config_text, ["model"]) + + @property + def managed(self) -> bool: + return self.credential is not None and self.credential.managed + + @property + def foreign(self) -> bool: + """Whether a `lightning` provider or key exists that `lightning code` didn't write.""" + return not self.managed and (self.provider is not None or self.credential is not None) + + +def read_state() -> State: + """Read the global config and auth.json. Raises ``jsonc.JSONCError`` if either can't be parsed.""" + path = config_path() + text = _read(path) + if text.strip(): + jsonc.loads(text) + + auth_text = _read(auth_path()) + auth = json.loads(auth_text) if auth_text.strip() else {} + if not isinstance(auth, dict): + raise jsonc.JSONCError(f"{auth_path()} is not a JSON object") + entry = auth.get(PROVIDER_ID) + credential = None + if isinstance(entry, dict) and entry.get("type") == "api" and isinstance(entry.get("key"), str): + credential = Credential(entry["key"], dict(entry.get("metadata") or {})) + return State(path, text, auth, credential) + + +@dataclass +class Plan: + """The changes `setup` makes, worked out before anything is written.""" + + state: State + config_text: str + set_model: Optional[str] + + def config_diff(self) -> str: + before = self.state.config_text.splitlines(keepends=True) + after = self.config_text.splitlines(keepends=True) + name = str(self.state.config_path) + return "".join(difflib.unified_diff(before, after, fromfile=name, tofile=name)) + + +def plan_setup(state: State, *, model: Optional[str]) -> Plan: + """Work out the new config: our provider block, and `model` only when asked or unset. + + ``model`` is a model key passed with --model; without it the default model is set only + when the user has no default of their own. + """ + text = state.config_text + if not text.strip(): + text = jsonc.set_value("", ["$schema"], SCHEMA_URL) + text = jsonc.set_value(text, ["provider", PROVIDER_ID], provider_config()) + + set_model = None + if model is not None: + set_model = model_ref(model) + elif state.model is None: + set_model = model_ref(CODING_MODELS[0].key) + if set_model is not None: + text = jsonc.set_value(text, ["model"], set_model) + return Plan(state, text, set_model) + + +def apply_setup(plan: Plan, *, key: str, metadata: dict[str, str]) -> Optional[Path]: + """Write auth.json and the config. Returns the backup of the config, if there was one to back up.""" + state = plan.state + auth = dict(state.auth) + auth[PROVIDER_ID] = {"type": "api", "key": key, "metadata": {"managed_by": MANAGED_BY, **metadata}} + _write(auth_path(), json.dumps(auth, indent=2) + "\n", mode=0o600) + + backup = None + if plan.config_text != state.config_text: + if state.config_text: + backup = state.config_path.with_name(state.config_path.name + BACKUP_SUFFIX) + shutil.copy2(state.config_path, backup) + _write(state.config_path, plan.config_text) + return backup + + +def remove(state: State) -> list[str]: + """Remove the provider, a default model on it, and the key. Returns what was removed.""" + removed = [] + text = state.config_text + if state.provider is not None: + text = jsonc.remove_value(text, ["provider", PROVIDER_ID]) + if jsonc.get_value(text, ["provider"]) == {}: + text = jsonc.remove_value(text, ["provider"]) + removed.append(f"the {PROVIDER_ID} provider from {state.config_path}") + # a default on the lightning provider can't work once the provider is gone, whoever set it + if isinstance(state.model, str) and state.model.startswith(f"{PROVIDER_ID}/"): + text = jsonc.remove_value(text, ["model"]) + removed.append(f"the default model {state.model}") + if text != state.config_text: + _write(state.config_path, text) + + if state.credential is not None: + auth = {k: v for k, v in state.auth.items() if k != PROVIDER_ID} + _write(auth_path(), json.dumps(auth, indent=2) + "\n", mode=0o600) + removed.append(f"the API key from {auth_path()}") + return removed + + +def _project_configs(start: Path) -> list[Path]: + """Project config files OpenCode would load in ``start``, walking up to the git root or home.""" + found = [] + home = Path.home() + for directory in [start, *start.parents]: + for name in ("opencode.json", "opencode.jsonc", ".opencode/opencode.json", ".opencode/opencode.jsonc"): + path = directory / name + if path.is_file() and path.parent != config_dir(): + found.append(path) + if directory == home or (directory / ".git").exists(): + break + return found + + +def warnings(state: State, *, cwd: Optional[Path] = None) -> list[str]: + """Settings elsewhere that would stop OpenCode from using what `setup` wrote.""" + notes = [f"{name} is set, which {effect}." for name, effect in _ENV_OVERRIDES.items() if os.environ.get(name)] + + config = jsonc.loads(state.config_text) if state.config_text.strip() else {} + enabled = config.get("enabled_providers") + if isinstance(enabled, list) and PROVIDER_ID not in enabled: + notes.append(f"enabled_providers in {state.config_path} doesn't include '{PROVIDER_ID}'.") + disabled = config.get("disabled_providers") + if isinstance(disabled, list) and PROVIDER_ID in disabled: + notes.append(f"disabled_providers in {state.config_path} includes '{PROVIDER_ID}'.") + + for path in _project_configs(cwd or Path.cwd()): + try: + project = jsonc.loads(_read(path)) + except jsonc.JSONCError: + continue + if not isinstance(project, dict): + continue + if isinstance(project.get("provider"), dict) and PROVIDER_ID in project["provider"]: + notes.append(f"{path} defines its own '{PROVIDER_ID}' provider, which wins in that project.") + if "model" in project: + notes.append(f"{path} sets model to {project['model']}, which wins in that project.") + return notes + + +def installed() -> bool: + return shutil.which("opencode") is not None diff --git a/python/lightning_sdk/cli/code/remove.py b/python/lightning_sdk/cli/code/remove.py new file mode 100644 index 000000000..c7580bcf0 --- /dev/null +++ b/python/lightning_sdk/cli/code/remove.py @@ -0,0 +1,46 @@ +"""lightning code remove: undo `lightning code setup`.""" + +import rich_click as click + +from lightning_sdk.cli.code import account, opencode +from lightning_sdk.cli.utils.logging import LightningCommand +from lightning_sdk.utils.jsonc import JSONCError + + +@click.command("remove", cls=LightningCommand) +@click.argument("tool", type=click.Choice(["opencode"], case_sensitive=False)) +@click.option("--keep-key", is_flag=True, default=False, help="Leave the API key active instead of revoking it.") +def remove(tool: str, keep_key: bool) -> None: + """Stop a coding tool from using code.lightning.ai. + + Removes the Lightning provider, the default model if it's a Lightning one, and the + API key, which is also revoked. The rest of the tool's config is left alone. + + Examples: + lightning code remove opencode + """ + try: + state = opencode.read_state() + except (JSONCError, ValueError) as exc: + raise click.ClickException(f"Couldn't read OpenCode's config: {exc}") from None + + credential = state.credential if state.managed else None + removed = opencode.remove(state) + if not removed: + click.echo("OpenCode isn't set up for code.lightning.ai.") + return + for item in removed: + click.echo(f"Removed {item}") + + if credential is None or not credential.key_id or not credential.org_id: + return + name = credential.metadata.get("key_name", credential.key_id) + if keep_key: + click.echo(f"The API key '{name}' is still active.") + elif account.revoke_key(credential.org_id, credential.key_id): + click.echo(f"Revoked the API key '{name}'.") + else: + click.echo( + f"Couldn't revoke the API key {credential.key_id}. Delete it with: " + f"lightning api-key delete {credential.key_id} --org {credential.metadata.get('org_name')}" + ) diff --git a/python/lightning_sdk/cli/code/setup.py b/python/lightning_sdk/cli/code/setup.py new file mode 100644 index 000000000..058700d03 --- /dev/null +++ b/python/lightning_sdk/cli/code/setup.py @@ -0,0 +1,112 @@ +"""lightning code setup: point a coding tool at code.lightning.ai.""" + +from typing import Optional + +import rich_click as click + +from lightning_sdk.cli.code import account, opencode +from lightning_sdk.cli.code.models import MODEL_KEYS +from lightning_sdk.cli.utils.logging import LightningCommand +from lightning_sdk.utils.jsonc import JSONCError + + +@click.command("setup", cls=LightningCommand) +@click.argument("tool", type=click.Choice(["opencode"], case_sensitive=False)) +@click.option("--org", help="Organization that pays, by name. Asked for when you belong to several.") +@click.option( + "--model", + type=click.Choice(MODEL_KEYS), + help="Make this the default model. Without it, GLM-5.3 becomes the default only if you have none.", +) +@click.option("--rotate-key", is_flag=True, default=False, help="Create a new API key and revoke the old one.") +@click.option("--dry-run", is_flag=True, default=False, help="Show the changes without writing or creating anything.") +@click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Replace a 'lightning' provider or key that `lightning code` didn't set up.", +) +def setup(tool: str, org: Optional[str], model: Optional[str], rotate_key: bool, dry_run: bool, yes: bool) -> None: + """Set up a coding tool to use code.lightning.ai. + + Adds Lightning as a model provider without touching the rest of the tool's config, + and creates an API key that bills the organization you choose. The organization + needs a Pro, Teams or Enterprise plan. Run it again to switch organizations or + pick up new models. + + Examples: + lightning code setup opencode --org my-org + """ + try: + state = opencode.read_state() + except (JSONCError, ValueError) as exc: + raise click.ClickException(f"Couldn't read OpenCode's config: {exc}") from None + + if state.foreign and not yes: + raise click.ClickException( + f"OpenCode already has a '{opencode.PROVIDER_ID}' provider or key that `lightning code` didn't set up.\n" + "Re-run with --yes to replace it. The config file is backed up first." + ) + + previous = state.credential if state.managed else None + chosen = account.choose_org(org, previous_org_id=previous.org_id if previous else None) + account.require_coding_plan(chosen) + + # the key set up before, when it bills the chosen org and still exists + reuse = None + if previous is not None and not rotate_key and previous.org_id == chosen.id and previous.key_id: + reuse = previous if account.key_exists(chosen.id, previous.key_id) else None + plan = opencode.plan_setup(state, model=model) + + if dry_run: + diff = plan.config_diff() + click.echo(diff if diff else f"No changes to {state.config_path}") + if reuse: + click.echo(f"Would keep the API key '{reuse.metadata.get('key_name', reuse.key_id)}'.") + else: + click.echo(f"Would create an API key billed to {chosen.label} and save it to {opencode.auth_path()}.") + if previous is not None and previous.key_id: + click.echo("Would revoke the API key set up before.") + return + + if reuse: + key, metadata = reuse.key, dict(reuse.metadata) + else: + name = account.default_key_name(tool) + created = account.create_key( + chosen, name, "For OpenCode on code.lightning.ai, created by `lightning code setup`" + ) + key = created.raw_key + metadata = {"org_id": chosen.id, "org_name": chosen.name, "key_id": created.id, "key_name": name} + metadata.pop("managed_by", None) + + try: + backup = opencode.apply_setup(plan, key=key, metadata=metadata) + except Exception: + if not reuse: + account.revoke_key(chosen.id, metadata["key_id"]) + raise + + click.echo(f"OpenCode now uses code.lightning.ai, billed to {chosen.label}.") + click.echo(f" Provider: {opencode.PROVIDER_ID} in {state.config_path}") + click.echo(f" API key: {metadata['key_name']} in {opencode.auth_path()}") + if plan.set_model: + click.echo(f" Default: {plan.set_model}") + if backup: + click.echo(f" Backup: {backup}") + + if previous is not None and not reuse and previous.key_id and previous.org_id: + if account.revoke_key(previous.org_id, previous.key_id): + click.echo(f"Revoked the previous API key '{previous.metadata.get('key_name', previous.key_id)}'.") + else: + click.echo( + f"Couldn't revoke the previous API key {previous.key_id}. " + f"Delete it with: lightning api-key delete {previous.key_id} --org {previous.metadata.get('org_name')}" + ) + + for note in opencode.warnings(state): + click.echo(f"Note: {note}") + if not opencode.installed(): + click.echo(f"\nOpenCode isn't on your PATH. Install it with:\n {opencode.INSTALL_COMMAND}") + click.echo("\nStart coding with `opencode`, and switch models with /models.") diff --git a/python/lightning_sdk/cli/code/status.py b/python/lightning_sdk/cli/code/status.py new file mode 100644 index 000000000..9c7bf9335 --- /dev/null +++ b/python/lightning_sdk/cli/code/status.py @@ -0,0 +1,55 @@ +"""lightning code status: which coding tools use code.lightning.ai.""" + +import rich_click as click + +from lightning_sdk.cli.code import opencode +from lightning_sdk.cli.utils.json_output import echo_json +from lightning_sdk.cli.utils.logging import LightningCommand +from lightning_sdk.utils.jsonc import JSONCError + + +@click.command("status", cls=LightningCommand) +@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.") +def status(as_json: bool = False) -> None: + """Show which coding tools are set up for code.lightning.ai, and which org they bill.""" + try: + state = opencode.read_state() + except (JSONCError, ValueError) as exc: + raise click.ClickException(f"Couldn't read OpenCode's config: {exc}") from None + + credential = state.credential + metadata = credential.metadata if credential else {} + info = { + "tool": "opencode", + "installed": opencode.installed(), + "configured": state.provider is not None and credential is not None, + "managed": state.managed, + "config": str(state.config_path), + "org": metadata.get("org_name"), + "key_name": metadata.get("key_name"), + "key_id": metadata.get("key_id"), + "model": state.model, + } + if as_json: + echo_json([info]) + return + + click.echo(opencode.NAME) + if not info["configured"]: + missing = "provider" if state.provider is None else "API key" + if state.provider is None and credential is None: + click.echo(" Not set up. Run: lightning code setup opencode") + else: + click.echo(f" Incomplete, the {missing} is missing. Run: lightning code setup opencode") + return + if not state.managed: + click.echo(f" Set up by hand in {state.config_path}, not by `lightning code`.") + return + click.echo(f" Org: {info['org']}") + click.echo(f" API key: {info['key_name']} ({info['key_id']})") + click.echo(f" Config: {info['config']}") + click.echo(f" Default: {info['model'] or 'none'}") + if not info["installed"]: + click.echo(f" OpenCode isn't on your PATH. Install it with: {opencode.INSTALL_COMMAND}") + for note in opencode.warnings(state): + click.echo(f" Note: {note}") diff --git a/python/lightning_sdk/cli/code/token.py b/python/lightning_sdk/cli/code/token.py new file mode 100644 index 000000000..e68565397 --- /dev/null +++ b/python/lightning_sdk/cli/code/token.py @@ -0,0 +1,47 @@ +"""lightning code token: create an API key for any coding tool.""" + +from typing import Optional + +import rich_click as click + +from lightning_sdk.cli.code import account +from lightning_sdk.cli.code.models import CODE_BASE_URL +from lightning_sdk.cli.utils.json_output import echo_json +from lightning_sdk.cli.utils.logging import LightningCommand + + +@click.command("token", cls=LightningCommand) +@click.option("--org", help="Organization that pays, by name. Asked for when you belong to several.") +@click.option("--name", help='Name for the key. Defaults to "code.lightning.ai on ".') +@click.option( + "--json", "as_json", is_flag=True, default=False, help="Output the key, its org and the base URL as JSON." +) +def token(org: Optional[str], name: Optional[str], as_json: bool = False) -> None: + """Create an API key for code.lightning.ai and print it. + + For tools `lightning code setup` doesn't configure: Claude Code, Cursor, Cline, + or your own scripts. The key bills the organization you choose, which needs a + Pro, Teams or Enterprise plan. It's shown only once. + + Examples: + export OPENAI_API_KEY=$(lightning code token --org my-org) + export OPENAI_BASE_URL=https://code.lightning.ai/v1 + """ + chosen = account.choose_org(org) + account.require_coding_plan(chosen) + key_name = name or account.default_key_name("code.lightning.ai") + created = account.create_key(chosen, key_name, "For code.lightning.ai, created by `lightning code token`") + + if as_json: + echo_json( + { + "api_key": created.raw_key, + "base_url": CODE_BASE_URL, + "key_id": created.id, + "key_name": key_name, + "org": chosen.name, + "org_id": chosen.id, + } + ) + return + click.echo(created.raw_key) diff --git a/python/lightning_sdk/cli/entrypoint.py b/python/lightning_sdk/cli/entrypoint.py index 584038dca..9db3e11f3 100644 --- a/python/lightning_sdk/cli/entrypoint.py +++ b/python/lightning_sdk/cli/entrypoint.py @@ -16,6 +16,7 @@ api_key, auth, base_studio, + code, config, connection, container, @@ -56,6 +57,7 @@ {"name": "TRAIN & DEPLOY", "commands": ["job", "mmt", "model", "deployment", "pipeline"]}, {"name": "ACCESS", "commands": ["user", "teamspace", "auth", "api-key", "ssh", "license"]}, {"name": "DATA & FILES", "commands": ["cp", "ls", "rm", "edit", "connection"]}, + {"name": "CODING", "commands": ["code"]}, ] } click.rich_click.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (1, 3) @@ -140,6 +142,7 @@ def logout() -> None: main_cli.add_command(pipeline) main_cli.add_command(api_key) main_cli.add_command(auth) +main_cli.add_command(code) main_cli.add_command(file) main_cli.add_command(folder) main_cli.add_command(ssh) diff --git a/python/lightning_sdk/cli/groups.py b/python/lightning_sdk/cli/groups.py index 4c659efd4..acbbb17bf 100644 --- a/python/lightning_sdk/cli/groups.py +++ b/python/lightning_sdk/cli/groups.py @@ -7,6 +7,7 @@ from lightning_sdk.cli.api_key import register_commands as register_api_key_commands from lightning_sdk.cli.auth import register_commands as register_auth_commands from lightning_sdk.cli.base_studio import register_commands as register_base_studio_commands +from lightning_sdk.cli.code import register_commands as register_code_commands from lightning_sdk.cli.config import register_commands as register_config_commands from lightning_sdk.cli.connection import register_commands as register_connection_commands from lightning_sdk.cli.container import register_commands as register_container_commands @@ -161,6 +162,11 @@ def api_key() -> None: """ +@click.group(name="code", cls=LightningGroup) +def code() -> None: + """Coding agents on code.lightning.ai.""" + + @click.group(name="auth", cls=LightningGroup) def auth() -> None: """Identity and access: who you are, and what you're allowed to do.""" @@ -279,6 +285,7 @@ def edit(_ctx: click.Context) -> None: register_pipeline_commands(pipeline) register_api_key_commands(api_key) register_auth_commands(auth) +register_code_commands(code) register_file_commands(file) register_folder_commands(folder) register_ssh_commands(ssh) diff --git a/python/lightning_sdk/utils/jsonc.py b/python/lightning_sdk/utils/jsonc.py new file mode 100644 index 000000000..3941ab164 --- /dev/null +++ b/python/lightning_sdk/utils/jsonc.py @@ -0,0 +1,321 @@ +"""Read and edit JSONC (JSON with comments and trailing commas) without reformatting it. + +Edits splice text into the original document, so everything outside the edited member +(comments, key order, indentation) stays byte-for-byte the same. +""" + +import json +from dataclasses import dataclass, field +from typing import Any, Optional, Sequence + + +class JSONCError(ValueError): + """The document is not valid JSONC.""" + + +@dataclass +class _Member: + key: str + start: int + end: int + value: "_Node" + + +@dataclass +class _Node: + start: int + end: int + kind: str # "object", "array" or "scalar" + members: list[_Member] = field(default_factory=list) + + def member(self, key: str) -> Optional[_Member]: + return next((m for m in self.members if m.key == key), None) + + +class _Parser: + def __init__(self, text: str, pos: int = 0) -> None: + self.text = text + self.pos = pos + + def fail(self, message: str) -> JSONCError: + line = self.text.count("\n", 0, self.pos) + 1 + return JSONCError(f"{message} at line {line}") + + def skip(self) -> None: + """Skip whitespace and comments.""" + text = self.text + while self.pos < len(text): + if text[self.pos] in " \t\r\n": + self.pos += 1 + elif text.startswith("//", self.pos): + newline = text.find("\n", self.pos) + self.pos = len(text) if newline < 0 else newline + 1 + elif text.startswith("/*", self.pos): + close = text.find("*/", self.pos + 2) + if close < 0: + raise self.fail("unterminated comment") + self.pos = close + 2 + else: + return + + def peek(self) -> str: + self.skip() + return self.text[self.pos] if self.pos < len(self.text) else "" + + def string(self) -> str: + start = self.pos + self.pos += 1 + while self.pos < len(self.text): + char = self.text[self.pos] + if char == "\\": + self.pos += 2 + elif char == '"': + self.pos += 1 + return json.loads(self.text[start : self.pos]) + else: + self.pos += 1 + raise self.fail("unterminated string") + + def value(self) -> _Node: + char = self.peek() + start = self.pos + if char == "{": + return self.obj() + if char == "[": + return self.array() + if char == '"': + self.string() + return _Node(start, self.pos, "scalar") + while self.pos < len(self.text) and self.text[self.pos] not in ',:[]{}/" \t\r\n': + self.pos += 1 + if self.pos == start: + raise self.fail("expected a value") + return _Node(start, self.pos, "scalar") + + def obj(self) -> _Node: + node = _Node(self.pos, self.pos, "object") + self.pos += 1 + while True: + char = self.peek() + if char == "}": + self.pos += 1 + node.end = self.pos + return node + if char != '"': + raise self.fail("expected a key") + start = self.pos + key = self.string() + if self.peek() != ":": + raise self.fail("expected ':'") + self.pos += 1 + value = self.value() + node.members.append(_Member(key, start, value.end, value)) + char = self.peek() + if char == ",": + self.pos += 1 + elif char != "}": + raise self.fail("expected ',' or '}'") + + def array(self) -> _Node: + node = _Node(self.pos, self.pos, "array") + self.pos += 1 + while True: + char = self.peek() + if char == "]": + self.pos += 1 + node.end = self.pos + return node + self.value() + char = self.peek() + if char == ",": + self.pos += 1 + elif char != "]": + raise self.fail("expected ',' or ']'") + + +def _parse(text: str) -> _Node: + parser = _Parser(text) + if parser.peek() == "": + raise JSONCError("empty document") + root = parser.value() + if parser.peek() != "": + raise parser.fail("unexpected content after the document") + return root + + +def _strip(text: str) -> str: + """Return ``text`` as plain JSON: comments blanked out and trailing commas dropped.""" + out: list[str] = [] + parser = _Parser(text) + comma: Optional[int] = None + while True: + before = parser.pos + parser.skip() + out.append(" " * (parser.pos - before)) + if parser.pos >= len(text): + return "".join(out) + if text[parser.pos] == '"': + start = parser.pos + parser.string() + token = text[start : parser.pos] + else: + token = text[parser.pos] + parser.pos += 1 + if token in "]}" and comma is not None: + out[comma] = " " + comma = len(out) if token == "," else None + out.append(token) + + +def loads(text: str) -> Any: + """Parse a JSONC document into Python values.""" + _parse(text) + try: + return json.loads(_strip(text)) + except json.JSONDecodeError as exc: + raise JSONCError(str(exc)) from None + + +def _line_start(text: str, pos: int) -> int: + return text.rfind("\n", 0, pos) + 1 + + +def _line_indent(text: str, pos: int) -> str: + line = text[_line_start(text, pos) : pos] + return line[: len(line) - len(line.lstrip(" \t"))] + + +def _starts_line(text: str, pos: int) -> bool: + return not text[_line_start(text, pos) : pos].strip() + + +def _line_rest(text: str, pos: int) -> int: + """Skip spaces and a ``//`` comment after ``pos``, stopping before the newline.""" + while pos < len(text) and text[pos] in " \t": + pos += 1 + if text.startswith("//", pos): + newline = text.find("\n", pos) + pos = len(text) if newline < 0 else newline + return pos + + +def _indent_unit(text: str) -> str: + for line in text.splitlines()[1:]: + stripped = line.lstrip(" \t") + if stripped and line != stripped: + return line[: len(line) - len(stripped)] + return " " + + +def _render(value: Any, indent: str, unit: str) -> str: + return json.dumps(value, indent=unit, ensure_ascii=False).replace("\n", "\n" + indent) + + +def _object_at(root: _Node, path: Sequence[str]) -> tuple[_Node, int]: + """Return the deepest object along ``path`` and how many keys of ``path`` it consumed.""" + node, depth = root, 0 + for key in path: + member = node.member(key) + if member is None or member.value.kind != "object": + break + node, depth = member.value, depth + 1 + return node, depth + + +def _root(text: str) -> _Node: + root = _parse(text) + if root.kind != "object": + raise JSONCError("the document is not a JSON object") + return root + + +def get_value(text: str, path: Sequence[str], default: Any = None) -> Any: + """Return the value at ``path``, or ``default`` when it is not set.""" + value = loads(text) if text.strip() else {} + for key in path: + if not isinstance(value, dict) or key not in value: + return default + value = value[key] + return value + + +def set_value(text: str, path: Sequence[str], value: Any) -> str: + """Set ``path`` to ``value``, creating missing objects along it, and return the new text.""" + if not path: + raise ValueError("path must not be empty") + if not text.strip(): + text = "{}\n" + unit = _indent_unit(text) + node, depth = _object_at(_root(text), path[:-1]) + + member = node.member(path[depth]) + if member is not None: + if depth < len(path) - 1: + raise JSONCError(f"'{'.'.join(path[: depth + 1])}' is not an object") + rendered = _render(value, _line_indent(text, member.start), unit) + return text[: member.value.start] + rendered + text[member.value.end :] + + # the missing tail of the path becomes one nested value, inserted as a new member + for key in reversed(path[depth + 1 :]): + value = {key: value} + key = json.dumps(path[depth]) + + if node.members: + last = node.members[-1] + parser = _Parser(text, last.end) + has_comma = parser.peek() == "," + after = parser.pos + 1 if has_comma else last.end + if not _starts_line(text, last.start): + rendered = f" {key}: {_render(value, _line_indent(text, last.start), unit)}" + return text[:after] + ("" if has_comma else ",") + rendered + ("," if has_comma else "") + text[after:] + indent = _line_indent(text, last.start) + insert_at = _line_rest(text, after) + rendered = f"\n{indent}{key}: {_render(value, indent, unit)}" + ("," if has_comma else "") + text = text[:insert_at] + rendered + text[insert_at:] + return text if has_comma else text[: last.end] + "," + text[last.end :] + + close = node.end - 1 + outer = _line_indent(text, node.start) + inner = text[node.start + 1 : close].rstrip(" \t") + if not inner.endswith("\n"): + inner += "\n" + rendered = f"{outer}{unit}{key}: {_render(value, outer + unit, unit)}\n{outer}" + return text[: node.start + 1] + inner + rendered + text[close:] + + +def remove_value(text: str, path: Sequence[str]) -> str: + """Remove the member at ``path`` when it exists, and return the new text.""" + if not path or not text.strip(): + return text + node, depth = _object_at(_root(text), path[:-1]) + if depth != len(path) - 1: + return text + index = next((i for i, m in enumerate(node.members) if m.key == path[-1]), None) + if index is None: + return text + member = node.members[index] + own_line = _starts_line(text, member.start) + + parser = _Parser(text, member.end) + if parser.peek() == ",": + # drop the member through its comma, and its whole line when it had one to itself + start = _line_start(text, member.start) if own_line else member.start + end = _line_rest(text, parser.pos + 1) + if own_line and text.startswith("\n", end): + end += 1 + return text[:start] + text[end:] + + # the last member: drop it along with its line, then the comma the member before it ended with + start = _line_start(text, member.start) - 1 if own_line else member.start + end = _line_rest(text, member.end) + if index == 0: + inner = text[node.start + 1 : start] + text[end : node.end - 1] + if not inner.strip(): + return text[: node.start + 1] + text[node.end - 1 :] + return text[:start] + text[end:] + + previous = _Parser(text, node.members[index - 1].end) + previous.peek() + comma = previous.pos + text = text[:start] + text[end:] + return text[:comma] + text[comma + 1 :] diff --git a/python/tests/cli/code/__init__.py b/python/tests/cli/code/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/tests/cli/code/test_code.py b/python/tests/cli/code/test_code.py new file mode 100644 index 000000000..ac958476e --- /dev/null +++ b/python/tests/cli/code/test_code.py @@ -0,0 +1,388 @@ +import json +import stat +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import click +import pytest +from click.testing import CliRunner, Result + +from lightning_sdk.cli.code import account, opencode +from lightning_sdk.cli.code.remove import remove +from lightning_sdk.cli.code.setup import setup +from lightning_sdk.cli.code.status import status +from lightning_sdk.cli.code.token import token +from lightning_sdk.utils import jsonc +from tests.cli.help import assert_help_contains, mock_command_logging + +USER_CONFIG = """{ + "$schema": "https://opencode.ai/config.json", + // keep this comment + "theme": "tokyonight", + "model": "anthropic/claude-sonnet-5-5" +} +""" + + +@pytest.fixture(autouse=True) +def opencode_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("HOME", str(tmp_path)) + for name in ("OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_AUTH_CONTENT"): + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _org(plan="Enterprise", name="my-org", org_id="org-1", **kwargs) -> account.CodingOrg: + fields = {"display_name": "", "personal": False, "coding_disabled": False, **kwargs} + return account.CodingOrg(id=org_id, name=name, plan=plan, **fields) + + +@pytest.fixture() +def api(): + """Mock everything that talks to Lightning: org choice, key creation and revocation.""" + keys = iter(range(1, 100)) + + def create_key(org, name, description): + n = next(keys) + return SimpleNamespace(id=f"key-{n}", name=name, raw_key=f"sk-lit-{n}") + + with patch.object(account, "choose_org", return_value=_org()) as choose, patch.object( + account, "create_key", side_effect=create_key + ) as create, patch.object(account, "key_exists", return_value=True) as exists, patch.object( + account, "revoke_key", return_value=True + ) as revoke: + yield SimpleNamespace(choose_org=choose, create_key=create, key_exists=exists, revoke_key=revoke) + + +def _invoke(command: click.Command, *args: str) -> Result: + with patch("lightning_sdk.cli.utils.logging._log_command", new=MagicMock()): + return CliRunner().invoke(command, list(args), catch_exceptions=False) + + +def _auth() -> dict: + return json.loads(opencode.auth_path().read_text()) + + +def _config_path() -> Path: + return opencode.config_dir() / "opencode.json" + + +@mock_command_logging +def test_code_help() -> None: + assert_help_contains("lightning code --help", "Usage: lightning code", "Coding agents on code.lightning.ai.") + assert_help_contains("lightning code setup --help", "Usage: lightning code setup", "--rotate-key") + assert_help_contains("lightning code token --help", "Usage: lightning code token", "Create an API key") + + +def test_setup_creates_config_and_key(api) -> None: + result = _invoke(setup, "opencode", "--org", "my-org") + assert result.exit_code == 0, result.output + + config = jsonc.loads(_config_path().read_text()) + assert config["$schema"] == opencode.SCHEMA_URL + assert config["model"] == "lightning/glm-5.3" + provider = config["provider"]["lightning"] + assert provider["options"]["baseURL"] == "https://code.lightning.ai/v1" + assert "apiKey" not in provider["options"] + assert set(provider["models"]) == {"glm-5.3", "glm-5.3-flash", "deepseek-v4.1-flash"} + + entry = _auth()["lightning"] + assert entry["type"] == "api" + assert entry["key"] == "sk-lit-1" + assert entry["metadata"] == { + "managed_by": "lightning-sdk", + "org_id": "org-1", + "org_name": "my-org", + "key_id": "key-1", + "key_name": api.create_key.call_args.args[1], + } + assert stat.S_IMODE(opencode.auth_path().stat().st_mode) == 0o600 + api.choose_org.assert_called_once_with("my-org", previous_org_id=None) + + +def test_setup_keeps_the_rest_of_the_users_files(api) -> None: + _config_path().parent.mkdir(parents=True) + _config_path().write_text(USER_CONFIG) + opencode.auth_path().parent.mkdir(parents=True) + opencode.auth_path().write_text(json.dumps({"anthropic": {"type": "api", "key": "sk-ant"}})) + + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + + text = _config_path().read_text() + assert "// keep this comment" in text + assert jsonc.remove_value(text, ["provider"]) == USER_CONFIG + # the user's own default model stays + assert jsonc.loads(text)["model"] == "anthropic/claude-sonnet-5-5" + assert _auth()["anthropic"] == {"type": "api", "key": "sk-ant"} + assert (opencode.config_dir() / "opencode.json.lightning-backup").read_text() == USER_CONFIG + + +def test_setup_prefers_the_jsonc_file(api) -> None: + opencode.config_dir().mkdir(parents=True) + (opencode.config_dir() / "opencode.json").write_text("{}") + (opencode.config_dir() / "opencode.jsonc").write_text("{\n // mine\n}\n") + + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert "lightning" in jsonc.loads((opencode.config_dir() / "opencode.jsonc").read_text())["provider"] + assert (opencode.config_dir() / "opencode.json").read_text() == "{}" + + +def test_setup_with_model_sets_the_default(api) -> None: + _config_path().parent.mkdir(parents=True) + _config_path().write_text(USER_CONFIG) + + assert _invoke(setup, "opencode", "--org", "my-org", "--model", "glm-5.3-flash").exit_code == 0 + assert jsonc.loads(_config_path().read_text())["model"] == "lightning/glm-5.3-flash" + + +def test_setup_rerun_reuses_the_key(api) -> None: + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + + assert api.create_key.call_count == 1 + assert _auth()["lightning"]["key"] == "sk-lit-1" + api.choose_org.assert_called_with("my-org", previous_org_id="org-1") + api.revoke_key.assert_not_called() + + +def test_setup_rotate_key_revokes_the_old_one(api) -> None: + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + result = _invoke(setup, "opencode", "--org", "my-org", "--rotate-key") + + assert result.exit_code == 0 + assert _auth()["lightning"]["key"] == "sk-lit-2" + api.revoke_key.assert_called_once_with("org-1", "key-1") + assert "Revoked the previous API key" in result.output + + +def test_setup_switching_org_creates_a_key_there(api) -> None: + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + api.choose_org.return_value = _org(name="other", org_id="org-2") + assert _invoke(setup, "opencode", "--org", "other").exit_code == 0 + + assert _auth()["lightning"]["metadata"]["org_id"] == "org-2" + api.revoke_key.assert_called_once_with("org-1", "key-1") + + +def test_setup_replaces_a_deleted_key(api) -> None: + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + api.key_exists.return_value = False + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert _auth()["lightning"]["key"] == "sk-lit-2" + + +def test_setup_free_plan_asks_to_upgrade(api) -> None: + api.choose_org.return_value = _org(plan="Free") + with patch.object(account, "_get_cloud_url", return_value="https://lightning.ai"): + result = CliRunner().invoke(setup, ["opencode", "--org", "my-org"]) + + assert result.exit_code == 1 + assert "Free plan" in result.output + assert "https://lightning.ai/me/settings/coding-usage?coding_org=org-1" in result.output + api.create_key.assert_not_called() + assert not _config_path().exists() + + +def test_setup_coding_disabled_org_is_refused(api) -> None: + api.choose_org.return_value = _org(coding_disabled=True) + result = CliRunner().invoke(setup, ["opencode", "--org", "my-org"]) + assert result.exit_code == 1 + assert "turned off" in result.output + api.create_key.assert_not_called() + + +def test_setup_will_not_replace_a_hand_made_provider_without_yes(api) -> None: + _config_path().parent.mkdir(parents=True) + _config_path().write_text('{"provider": {"lightning": {"options": {"apiKey": "sk-lit-old"}}}}') + + result = CliRunner().invoke(setup, ["opencode", "--org", "my-org"]) + assert result.exit_code == 1 + assert "--yes" in result.output + api.create_key.assert_not_called() + + assert _invoke(setup, "opencode", "--org", "my-org", "--yes").exit_code == 0 + assert "apiKey" not in jsonc.loads(_config_path().read_text())["provider"]["lightning"]["options"] + + +def test_setup_dry_run_writes_nothing(api) -> None: + result = _invoke(setup, "opencode", "--org", "my-org", "--dry-run") + + assert result.exit_code == 0 + assert '+ "lightning": {' in result.output + assert "Would create an API key" in result.output + assert not _config_path().exists() + assert not opencode.auth_path().exists() + api.create_key.assert_not_called() + + +def test_setup_invalid_config_fails_before_creating_a_key(api) -> None: + _config_path().parent.mkdir(parents=True) + _config_path().write_text('{"theme": }') + + result = CliRunner().invoke(setup, ["opencode", "--org", "my-org"]) + assert result.exit_code == 1 + assert "Couldn't read OpenCode's config" in result.output + api.create_key.assert_not_called() + + +def test_setup_revokes_the_new_key_when_writing_fails(api) -> None: + with patch.object(opencode, "apply_setup", side_effect=OSError("disk full")), pytest.raises( + OSError, match="disk full" + ): + _invoke(setup, "opencode", "--org", "my-org") + api.revoke_key.assert_called_once_with("org-1", "key-1") + + +def test_status_and_remove(api) -> None: + _config_path().parent.mkdir(parents=True) + _config_path().write_text("{\n // keep this comment\n}\n") + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + + result = _invoke(status, "--json") + [info] = json.loads(result.output) + assert info["configured"] is True + assert info["managed"] is True + assert info["org"] == "my-org" + assert info["key_id"] == "key-1" + assert info["model"] == "lightning/glm-5.3" + + result = _invoke(remove, "opencode") + assert result.exit_code == 0 + assert "Revoked the API key" in result.output + api.revoke_key.assert_called_once_with("org-1", "key-1") + assert _config_path().read_text() == "{\n // keep this comment\n}\n" + assert "lightning" not in _auth() + + assert "isn't set up" in _invoke(remove, "opencode").output + assert "Not set up" in _invoke(status).output + + +def test_remove_takes_out_a_lightning_default_the_user_set(api) -> None: + _config_path().parent.mkdir(parents=True) + _config_path().write_text('{"provider": {"lightning": {}}, "model": "lightning/glm-5.3", "theme": "x"}') + assert _invoke(setup, "opencode", "--org", "my-org", "--yes").exit_code == 0 + + assert _invoke(remove, "opencode").exit_code == 0 + assert jsonc.loads(_config_path().read_text()) == {"theme": "x"} + + +def test_remove_keeps_a_default_model_the_user_changed(api) -> None: + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + _config_path().write_text(jsonc.set_value(_config_path().read_text(), ["model"], "anthropic/claude")) + + assert _invoke(remove, "opencode", "--keep-key").exit_code == 0 + assert jsonc.loads(_config_path().read_text()) == { + "$schema": opencode.SCHEMA_URL, + "model": "anthropic/claude", + } + api.revoke_key.assert_not_called() + + +def test_warnings_point_at_overrides(opencode_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENCODE_AUTH_CONTENT", "{}") + _config_path().parent.mkdir(parents=True) + _config_path().write_text('{"enabled_providers": ["anthropic"], "disabled_providers": ["lightning"]}') + project = opencode_home / "repo" + (project / ".git").mkdir(parents=True) + (project / "opencode.json").write_text('{"model": "anthropic/claude"}') + + notes = opencode.warnings(opencode.read_state(), cwd=project) + + assert any("OPENCODE_AUTH_CONTENT" in note for note in notes) + assert any("enabled_providers" in note for note in notes) + assert any("disabled_providers" in note for note in notes) + assert any("sets model to anthropic/claude" in note for note in notes) + + +def test_token_prints_a_new_key(api) -> None: + result = _invoke(token, "--org", "my-org") + assert result.exit_code == 0 + assert result.output == "sk-lit-1\n" + + result = _invoke(token, "--org", "my-org", "--name", "ci", "--json") + assert json.loads(result.output) == { + "api_key": "sk-lit-2", + "base_url": "https://code.lightning.ai/v1", + "key_id": "key-2", + "key_name": "ci", + "org": "my-org", + "org_id": "org-1", + } + + +def test_token_free_plan_asks_to_upgrade(api) -> None: + api.choose_org.return_value = _org(plan="Free") + result = CliRunner().invoke(token, ["--org", "my-org"]) + assert result.exit_code == 1 + assert "Upgrade to Pro or Teams" in result.output + api.create_key.assert_not_called() + + +def _v1_org(name: str, org_id: str, personal: bool = False) -> SimpleNamespace: + return SimpleNamespace( + id=org_id, name=name, display_name=name.title(), is_personal_org=personal, disable_coding_agents=False + ) + + +@pytest.fixture() +def client(inline_executor_cls): + client = MagicMock() + client.organizations_service_list_organizations.return_value.organizations = [ + _v1_org("zeta", "org-z"), + _v1_org("alpha", "org-a"), + _v1_org("me", "org-me", personal=True), + ] + plans = {"org-z": "Teams", "org-a": "Free", "org-me": "Professional"} + client.billing_service_get_billing_subscription.side_effect = lambda org_id: SimpleNamespace(name=plans[org_id]) + with patch.object(account, "cached_lightning_client", return_value=client), patch.object( + account, "ThreadPoolExecutor", inline_executor_cls + ): + yield client + + +def test_choose_org_by_name_reads_only_that_plan(client) -> None: + org = account.choose_org("zeta") + assert (org.id, org.plan, org.tier) == ("org-z", "Teams", "teams") + client.billing_service_get_billing_subscription.assert_called_once_with(org_id="org-z") + + +def test_choose_org_needs_org_without_a_terminal(client) -> None: + with patch.object(account, "_interactive", return_value=False), pytest.raises(click.UsageError, match="--org"): + account.choose_org(None) + + +def test_choose_org_asks_with_paid_orgs_preselected(client) -> None: + with patch.object(account, "_interactive", return_value=True), patch.object( + account.click, "prompt", return_value=3 + ) as prompt, patch.object(account.click, "echo") as echo: + org = account.choose_org(None) + + shown = [call.args[0] for call in echo.call_args_list] + assert shown[1:] == [ + " 1. Me (personal) (Professional)", + " 2. Alpha (Free, upgrade needed)", + " 3. Zeta (Teams)", + ] + assert prompt.call_args.kwargs["default"] == 1 + assert org.id == "org-z" + + +def test_choose_org_unknown_name(client) -> None: + with pytest.raises(click.UsageError, match="alpha, zeta"): + account.choose_org("nope") + + +def test_unreadable_plan_is_allowed_with_a_note() -> None: + with patch.object(account.click, "echo") as echo: + account.require_coding_plan(_org(plan=None)) + assert "Couldn't read the plan" in echo.call_args.args[0] + + +def test_example_config_matches_what_setup_writes() -> None: + example = Path(__file__).parents[3] / "examples" / "code" / "opencode.json" + plan = opencode.plan_setup(opencode.read_state(), model=None) + assert plan.config_text == example.read_text() diff --git a/python/tests/utils/test_jsonc.py b/python/tests/utils/test_jsonc.py new file mode 100644 index 000000000..2c690a0cd --- /dev/null +++ b/python/tests/utils/test_jsonc.py @@ -0,0 +1,100 @@ +import pytest + +from lightning_sdk.utils import jsonc + +USER_CONFIG = """{ + "$schema": "https://opencode.ai/config.json", + // my theme, "with" {braces} and // slashes + "theme": "tokyonight", + "provider": { + "anthropic": { "options": { "timeout": 1000 } }, // keep me + }, + /* block comment */ + "url": "http://example.com/*not a comment*/", + "model": "anthropic/claude", +} +""" + + +def test_loads_accepts_comments_and_trailing_commas() -> None: + assert jsonc.loads(USER_CONFIG) == { + "$schema": "https://opencode.ai/config.json", + "theme": "tokyonight", + "provider": {"anthropic": {"options": {"timeout": 1000}}}, + "url": "http://example.com/*not a comment*/", + "model": "anthropic/claude", + } + + +@pytest.mark.parametrize("text", ['{"a": }', '{"a": 1', '{"a" 1}', "[1, 2", '{"a": 1} x', "/* open"]) +def test_loads_rejects_invalid_documents(text: str) -> None: + with pytest.raises(jsonc.JSONCError): + jsonc.loads(text) + + +def test_set_value_only_touches_the_member() -> None: + updated = jsonc.set_value(USER_CONFIG, ["provider", "lightning"], {"name": "Lightning AI"}) + + assert jsonc.loads(updated)["provider"] == { + "anthropic": {"options": {"timeout": 1000}}, + "lightning": {"name": "Lightning AI"}, + } + # everything around the new member is unchanged, comments included + assert updated.replace(' "lightning": {\n "name": "Lightning AI"\n },\n', "") == USER_CONFIG + + +def test_set_value_replaces_in_place_and_remove_restores_the_original() -> None: + first = jsonc.set_value(USER_CONFIG, ["provider", "lightning"], {"name": "one", "models": {"a": {}}}) + second = jsonc.set_value(first, ["provider", "lightning"], {"name": "two"}) + + assert jsonc.get_value(second, ["provider", "lightning"]) == {"name": "two"} + assert jsonc.remove_value(second, ["provider", "lightning"]) == USER_CONFIG + + +@pytest.mark.parametrize( + "text", + [ + "", + "{}", + "{}\n", + '{\n "a": 1\n}\n', + '{\n\t"a": 1,\n}\n', + '{"a": 1}', + '{ "provider": {} }', + '{"provider": {"x": 1}}', + '{\n "provider": {\n "x": 1 // note\n }\n}\n', + ], +) +def test_set_then_remove_round_trips(text: str) -> None: + updated = jsonc.set_value(text, ["provider", "lightning"], {"k": [1, 2]}) + assert jsonc.get_value(updated, ["provider", "lightning"]) == {"k": [1, 2]} + + removed = jsonc.remove_value(updated, ["provider", "lightning"]) + assert jsonc.get_value(removed, ["provider", "lightning"]) is None + if text.strip(): + assert jsonc.loads(removed) == jsonc.loads(text) | ({"provider": {}} if "provider" not in text else {}) + + +def test_set_value_follows_the_file_indentation() -> None: + updated = jsonc.set_value('{\n\t"a": 1\n}\n', ["b"], {"c": 1}) + assert updated == '{\n\t"a": 1,\n\t"b": {\n\t\t"c": 1\n\t}\n}\n' + + +def test_set_value_refuses_to_descend_into_a_scalar() -> None: + with pytest.raises(jsonc.JSONCError): + jsonc.set_value('{"provider": "oops"}', ["provider", "lightning"], {}) + + +def test_remove_value_of_missing_member_is_a_no_op() -> None: + assert jsonc.remove_value(USER_CONFIG, ["provider", "missing"]) == USER_CONFIG + assert jsonc.remove_value(USER_CONFIG, ["nope", "missing"]) == USER_CONFIG + + +def test_remove_value_of_middle_member() -> None: + text = '{\n "a": 1,\n "b": 2, // about b\n "c": 3\n}\n' + assert jsonc.remove_value(text, ["b"]) == '{\n "a": 1,\n "c": 3\n}\n' + + +def test_remove_value_of_last_member() -> None: + text = '{\n "a": 1, // about a\n "b": 2\n}\n' + assert jsonc.remove_value(text, ["b"]) == '{\n "a": 1 // about a\n}\n' From 5b72f5ac7c6cad5d1e2131cdef0066c162ca8eab Mon Sep 17 00:00:00 2001 From: Karolis Date: Fri, 9 Oct 2026 15:36:42 +0400 Subject: [PATCH 2/3] feat(cli): default to DeepSeek V4.1 Flash and rename --yes to --force in `lightning code setup` --- python/examples/code/opencode.json | 2 +- python/examples/code_cli.rst | 10 +++++----- python/lightning_sdk/cli/code/models.py | 3 ++- python/lightning_sdk/cli/code/opencode.py | 10 ++++++++-- python/lightning_sdk/cli/code/setup.py | 12 ++++++------ python/tests/cli/code/test_code.py | 18 ++++++++++++------ 6 files changed, 34 insertions(+), 21 deletions(-) diff --git a/python/examples/code/opencode.json b/python/examples/code/opencode.json index 32d03c80a..0c3cbda3a 100644 --- a/python/examples/code/opencode.json +++ b/python/examples/code/opencode.json @@ -55,5 +55,5 @@ } } }, - "model": "lightning/glm-5.3" + "model": "lightning/deepseek-v4.1-flash" } diff --git a/python/examples/code_cli.rst b/python/examples/code_cli.rst index 86712c2a1..e4dcc9924 100644 --- a/python/examples/code_cli.rst +++ b/python/examples/code_cli.rst @@ -22,7 +22,7 @@ Set up OpenCode OpenCode now uses code.lightning.ai, billed to My Org [my-org]. Provider: lightning in ~/.config/opencode/opencode.json API key: opencode on my-laptop 2026-10-09 in ~/.local/share/opencode/auth.json - Default: lightning/glm-5.3 + Default: lightning/deepseek-v4.1-flash $ opencode @@ -37,7 +37,7 @@ The setup leaves the rest of OpenCode's config alone: - The API key goes to OpenCode's credential store, ``auth.json``, next to the keys ``opencode auth login`` saves, never into the config file. Its metadata records the organization and key, as in `code/opencode-auth.json `_. -- ``lightning/glm-5.3`` becomes the default model only if you have no default yet. +- ``lightning/deepseek-v4.1-flash`` becomes the default model only if you have no default yet. Pass ``--model`` to set one anyway. Preview the changes first, or pick a different default model: @@ -45,7 +45,7 @@ Preview the changes first, or pick a different default model: .. code-block:: console $ lightning code setup opencode --org my-org --dry-run - $ lightning code setup opencode --org my-org --model glm-5.3-flash + $ lightning code setup opencode --org my-org --model glm-5.3 Run it again to switch organizations, which creates a key there and revokes the old one, or to pick up new models. ``--rotate-key`` replaces the key in place. @@ -60,7 +60,7 @@ Check and undo the setup Org: my-org API key: opencode on my-laptop 2026-10-09 (01jexamplekey0000000000000) Config: ~/.config/opencode/opencode.json - Default: lightning/glm-5.3 + Default: lightning/deepseek-v4.1-flash $ lightning code remove opencode @@ -80,7 +80,7 @@ or Anthropic API. The key is printed once, so store it straight away. $ export ANTHROPIC_BASE_URL=https://code.lightning.ai $ export ANTHROPIC_AUTH_TOKEN=$(lightning code token --org my-org --name claude-code) - $ claude --model glm-5.3 + $ claude --model deepseek-v4.1-flash $ lightning code token --org my-org --json diff --git a/python/lightning_sdk/cli/code/models.py b/python/lightning_sdk/cli/code/models.py index 1b3dfe116..d3df7190b 100644 --- a/python/lightning_sdk/cli/code/models.py +++ b/python/lightning_sdk/cli/code/models.py @@ -24,5 +24,6 @@ class CodingModel: CodingModel("glm-5.3-flash", "lightning-ai/glm-5.3-flash", "GLM-5.3 Flash", 253_952, images=True), CodingModel("deepseek-v4.1-flash", "lightning-ai/deepseek-v4.1-flash", "DeepSeek V4.1 Flash", 253_952), ) -DEFAULT_MODEL = CODING_MODELS[0].key +# the model `lightning code setup` makes the default when the user has none +DEFAULT_MODEL = "deepseek-v4.1-flash" MODEL_KEYS = tuple(model.key for model in CODING_MODELS) diff --git a/python/lightning_sdk/cli/code/opencode.py b/python/lightning_sdk/cli/code/opencode.py index 64a7172a1..9eabd4bfd 100644 --- a/python/lightning_sdk/cli/code/opencode.py +++ b/python/lightning_sdk/cli/code/opencode.py @@ -16,7 +16,13 @@ from pathlib import Path from typing import Any, Optional -from lightning_sdk.cli.code.models import CODE_BASE_URL, CODING_MODELS, OUTPUT_TOKENS, REQUEST_TIMEOUT_MS +from lightning_sdk.cli.code.models import ( + CODE_BASE_URL, + CODING_MODELS, + DEFAULT_MODEL, + OUTPUT_TOKENS, + REQUEST_TIMEOUT_MS, +) from lightning_sdk.utils import jsonc NAME = "OpenCode" @@ -201,7 +207,7 @@ def plan_setup(state: State, *, model: Optional[str]) -> Plan: if model is not None: set_model = model_ref(model) elif state.model is None: - set_model = model_ref(CODING_MODELS[0].key) + set_model = model_ref(DEFAULT_MODEL) if set_model is not None: text = jsonc.set_value(text, ["model"], set_model) return Plan(state, text, set_model) diff --git a/python/lightning_sdk/cli/code/setup.py b/python/lightning_sdk/cli/code/setup.py index 058700d03..a74cddfd7 100644 --- a/python/lightning_sdk/cli/code/setup.py +++ b/python/lightning_sdk/cli/code/setup.py @@ -16,18 +16,18 @@ @click.option( "--model", type=click.Choice(MODEL_KEYS), - help="Make this the default model. Without it, GLM-5.3 becomes the default only if you have none.", + help="Make this the default model. Without it, DeepSeek V4.1 Flash becomes the default only if you have none.", ) @click.option("--rotate-key", is_flag=True, default=False, help="Create a new API key and revoke the old one.") @click.option("--dry-run", is_flag=True, default=False, help="Show the changes without writing or creating anything.") @click.option( - "--yes", - "-y", + "--force", + "-f", is_flag=True, default=False, help="Replace a 'lightning' provider or key that `lightning code` didn't set up.", ) -def setup(tool: str, org: Optional[str], model: Optional[str], rotate_key: bool, dry_run: bool, yes: bool) -> None: +def setup(tool: str, org: Optional[str], model: Optional[str], rotate_key: bool, dry_run: bool, force: bool) -> None: """Set up a coding tool to use code.lightning.ai. Adds Lightning as a model provider without touching the rest of the tool's config, @@ -43,10 +43,10 @@ def setup(tool: str, org: Optional[str], model: Optional[str], rotate_key: bool, except (JSONCError, ValueError) as exc: raise click.ClickException(f"Couldn't read OpenCode's config: {exc}") from None - if state.foreign and not yes: + if state.foreign and not force: raise click.ClickException( f"OpenCode already has a '{opencode.PROVIDER_ID}' provider or key that `lightning code` didn't set up.\n" - "Re-run with --yes to replace it. The config file is backed up first." + "Re-run with --force to replace it. The config file is backed up first." ) previous = state.credential if state.managed else None diff --git a/python/tests/cli/code/test_code.py b/python/tests/cli/code/test_code.py index ac958476e..cf37e8801 100644 --- a/python/tests/cli/code/test_code.py +++ b/python/tests/cli/code/test_code.py @@ -9,6 +9,7 @@ from click.testing import CliRunner, Result from lightning_sdk.cli.code import account, opencode +from lightning_sdk.cli.code.models import DEFAULT_MODEL from lightning_sdk.cli.code.remove import remove from lightning_sdk.cli.code.setup import setup from lightning_sdk.cli.code.status import status @@ -84,7 +85,7 @@ def test_setup_creates_config_and_key(api) -> None: config = jsonc.loads(_config_path().read_text()) assert config["$schema"] == opencode.SCHEMA_URL - assert config["model"] == "lightning/glm-5.3" + assert config["model"] == "lightning/deepseek-v4.1-flash" provider = config["provider"]["lightning"] assert provider["options"]["baseURL"] == "https://code.lightning.ai/v1" assert "apiKey" not in provider["options"] @@ -195,16 +196,16 @@ def test_setup_coding_disabled_org_is_refused(api) -> None: api.create_key.assert_not_called() -def test_setup_will_not_replace_a_hand_made_provider_without_yes(api) -> None: +def test_setup_will_not_replace_a_hand_made_provider_without_force(api) -> None: _config_path().parent.mkdir(parents=True) _config_path().write_text('{"provider": {"lightning": {"options": {"apiKey": "sk-lit-old"}}}}') result = CliRunner().invoke(setup, ["opencode", "--org", "my-org"]) assert result.exit_code == 1 - assert "--yes" in result.output + assert "--force" in result.output api.create_key.assert_not_called() - assert _invoke(setup, "opencode", "--org", "my-org", "--yes").exit_code == 0 + assert _invoke(setup, "opencode", "--org", "my-org", "--force").exit_code == 0 assert "apiKey" not in jsonc.loads(_config_path().read_text())["provider"]["lightning"]["options"] @@ -248,7 +249,7 @@ def test_status_and_remove(api) -> None: assert info["managed"] is True assert info["org"] == "my-org" assert info["key_id"] == "key-1" - assert info["model"] == "lightning/glm-5.3" + assert info["model"] == "lightning/deepseek-v4.1-flash" result = _invoke(remove, "opencode") assert result.exit_code == 0 @@ -264,7 +265,7 @@ def test_status_and_remove(api) -> None: def test_remove_takes_out_a_lightning_default_the_user_set(api) -> None: _config_path().parent.mkdir(parents=True) _config_path().write_text('{"provider": {"lightning": {}}, "model": "lightning/glm-5.3", "theme": "x"}') - assert _invoke(setup, "opencode", "--org", "my-org", "--yes").exit_code == 0 + assert _invoke(setup, "opencode", "--org", "my-org", "--force").exit_code == 0 assert _invoke(remove, "opencode").exit_code == 0 assert jsonc.loads(_config_path().read_text()) == {"theme": "x"} @@ -386,3 +387,8 @@ def test_example_config_matches_what_setup_writes() -> None: example = Path(__file__).parents[3] / "examples" / "code" / "opencode.json" plan = opencode.plan_setup(opencode.read_state(), model=None) assert plan.config_text == example.read_text() + + +def test_default_model_is_one_setup_configures() -> None: + assert DEFAULT_MODEL == "deepseek-v4.1-flash" + assert DEFAULT_MODEL in opencode.provider_config()["models"] From 33c0d17ab3ea5e5300463dafe3b96cc43c3e9e70 Mon Sep 17 00:00:00 2001 From: Karolis Date: Fri, 9 Oct 2026 18:20:56 +0400 Subject: [PATCH 3/3] feat(cli): read code.lightning.ai's models from /v1/models in `lightning code setup` --- python/examples/code/opencode.json | 22 ++-- python/examples/code_cli.rst | 3 + python/lightning_sdk/cli/code/models.py | 73 +++++++++++-- python/lightning_sdk/cli/code/opencode.py | 54 +++++----- python/lightning_sdk/cli/code/setup.py | 17 ++- python/tests/cli/code/models_response.json | 103 ++++++++++++++++++ python/tests/cli/code/test_code.py | 115 ++++++++++++++++++++- 7 files changed, 337 insertions(+), 50 deletions(-) create mode 100644 python/tests/cli/code/models_response.json diff --git a/python/examples/code/opencode.json b/python/examples/code/opencode.json index 0c3cbda3a..2bc36cb8c 100644 --- a/python/examples/code/opencode.json +++ b/python/examples/code/opencode.json @@ -13,19 +13,19 @@ "id": "lightning-ai/glm-5.3", "name": "GLM-5.3", "reasoning": true, - "tool_call": true, "interleaved": "reasoning_content", + "tool_call": true, "limit": { "context": 1048576, - "output": 32768 + "output": 131072 } }, "glm-5.3-flash": { "id": "lightning-ai/glm-5.3-flash", "name": "GLM-5.3 Flash", "reasoning": true, - "tool_call": true, "interleaved": "reasoning_content", + "tool_call": true, "attachment": true, "modalities": { "input": [ @@ -38,18 +38,28 @@ }, "limit": { "context": 253952, - "output": 32768 + "output": 131072 } }, "deepseek-v4.1-flash": { "id": "lightning-ai/deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", "reasoning": true, - "tool_call": true, "interleaved": "reasoning_content", + "tool_call": true, + "attachment": true, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, "limit": { "context": 253952, - "output": 32768 + "output": 131072 } } } diff --git a/python/examples/code_cli.rst b/python/examples/code_cli.rst index e4dcc9924..a735b88fc 100644 --- a/python/examples/code_cli.rst +++ b/python/examples/code_cli.rst @@ -39,6 +39,9 @@ The setup leaves the rest of OpenCode's config alone: the organization and key, as in `code/opencode-auth.json `_. - ``lightning/deepseek-v4.1-flash`` becomes the default model only if you have no default yet. Pass ``--model`` to set one anyway. +- The models, with their names, context windows, output limits and image support, + come from ``https://code.lightning.ai/v1/models``, so new models appear without + a CLI update. If it can't be reached, a built-in list is used. Preview the changes first, or pick a different default model: diff --git a/python/lightning_sdk/cli/code/models.py b/python/lightning_sdk/cli/code/models.py index d3df7190b..69a34ca77 100644 --- a/python/lightning_sdk/cli/code/models.py +++ b/python/lightning_sdk/cli/code/models.py @@ -1,11 +1,16 @@ """The models code.lightning.ai serves, as coding tools need to be told about them.""" from dataclasses import dataclass +from typing import Any, Optional + +import requests CODE_BASE_URL = "https://code.lightning.ai/v1" # OpenAI-compatible tools wait this long for a response before giving up REQUEST_TIMEOUT_MS = 600_000 -OUTPUT_TOKENS = 32_768 +# the model `lightning code setup` makes the default when the user has none +DEFAULT_MODEL = "deepseek-v4.1-flash" +_FETCH_TIMEOUT_S = 10 @dataclass(frozen=True) @@ -15,15 +20,65 @@ class CodingModel: id: str name: str context_window: int + output_tokens: int images: bool = False + tools: bool = True + reasoning: bool = True -# Keep in sync with lightning-ui's CodingUsage/harnesses.ts, which shows the same configs on the web. -CODING_MODELS = ( - CodingModel("glm-5.3", "lightning-ai/glm-5.3", "GLM-5.3", 1_048_576), - CodingModel("glm-5.3-flash", "lightning-ai/glm-5.3-flash", "GLM-5.3 Flash", 253_952, images=True), - CodingModel("deepseek-v4.1-flash", "lightning-ai/deepseek-v4.1-flash", "DeepSeek V4.1 Flash", 253_952), +# Used when /v1/models can't be reached; matches what it served on 2026-10-09. +FALLBACK_MODELS = ( + CodingModel("glm-5.3", "lightning-ai/glm-5.3", "GLM-5.3", 1_048_576, 131_072), + CodingModel("glm-5.3-flash", "lightning-ai/glm-5.3-flash", "GLM-5.3 Flash", 253_952, 131_072, images=True), + CodingModel( + "deepseek-v4.1-flash", "lightning-ai/deepseek-v4.1-flash", "DeepSeek V4.1 Flash", 253_952, 131_072, images=True + ), ) -# the model `lightning code setup` makes the default when the user has none -DEFAULT_MODEL = "deepseek-v4.1-flash" -MODEL_KEYS = tuple(model.key for model in CODING_MODELS) + + +def _parse(entry: Any) -> Optional[CodingModel]: + """Read one OpenRouter-style /v1/models entry, or None when it lacks what a tool config needs.""" + if not isinstance(entry, dict) or not isinstance(entry.get("id"), str): + return None + model_id = entry["id"] + provider = entry.get("top_provider") or {} + context = entry.get("context_length") or provider.get("context_length") + output = provider.get("max_completion_tokens") + if not isinstance(context, int) or not isinstance(output, int): + return None + inputs = (entry.get("architecture") or {}).get("input_modalities") or ["text"] + parameters = entry.get("supported_parameters") + return CodingModel( + key=model_id.split("/", 1)[-1], + id=model_id, + name=entry.get("name") or model_id, + context_window=context, + output_tokens=output, + images="image" in inputs, + # an entry that doesn't list its parameters is assumed to support them, as every model does today + tools=parameters is None or "tools" in parameters, + reasoning=parameters is None or "reasoning_effort" in parameters, + ) + + +def fetch_models() -> tuple[CodingModel, ...]: + """The models /v1/models lists right now. Raises when it can't be read or lists none.""" + response = requests.get(f"{CODE_BASE_URL}/models", timeout=_FETCH_TIMEOUT_S) + response.raise_for_status() + models = tuple(model for model in map(_parse, response.json().get("data") or []) if model is not None) + if not models: + raise ValueError("it listed no models") + return models + + +def coding_models() -> tuple[tuple[CodingModel, ...], Optional[str]]: + """The models to configure, and why the built-in list was used instead of /v1/models, if it was.""" + try: + return fetch_models(), None + except Exception as exc: + return FALLBACK_MODELS, str(exc) or type(exc).__name__ + + +def default_model(models: tuple[CodingModel, ...]) -> str: + keys = [model.key for model in models] + return DEFAULT_MODEL if DEFAULT_MODEL in keys else keys[0] diff --git a/python/lightning_sdk/cli/code/opencode.py b/python/lightning_sdk/cli/code/opencode.py index 9eabd4bfd..a437928c1 100644 --- a/python/lightning_sdk/cli/code/opencode.py +++ b/python/lightning_sdk/cli/code/opencode.py @@ -14,15 +14,9 @@ import stat from dataclasses import dataclass from pathlib import Path -from typing import Any, Optional - -from lightning_sdk.cli.code.models import ( - CODE_BASE_URL, - CODING_MODELS, - DEFAULT_MODEL, - OUTPUT_TOKENS, - REQUEST_TIMEOUT_MS, -) +from typing import Any, Optional, Sequence + +from lightning_sdk.cli.code.models import CODE_BASE_URL, REQUEST_TIMEOUT_MS, CodingModel, default_model from lightning_sdk.utils import jsonc NAME = "OpenCode" @@ -64,27 +58,28 @@ def config_path() -> Path: return config_dir() / "opencode.json" -def provider_config() -> dict[str, Any]: - """The `provider.lightning` block. It has no apiKey: OpenCode reads that from auth.json.""" - models = {} - for model in CODING_MODELS: - entry: dict[str, Any] = { - "id": model.id, - "name": model.name, - "reasoning": True, - "tool_call": True, - "interleaved": "reasoning_content", - } +def provider_config(models: Sequence[CodingModel]) -> dict[str, Any]: + """The `provider.lightning` block. It has no apiKey: OpenCode reads that from auth.json. + + OpenCode sends at most 32,000 as max_tokens whatever ``limit.output`` says, and keeps + prompts within ``limit.context`` minus that, so the full output limit is safe to state. + """ + entries = {} + for model in models: + entry: dict[str, Any] = {"id": model.id, "name": model.name, "reasoning": model.reasoning} + if model.reasoning: + entry["interleaved"] = "reasoning_content" + entry["tool_call"] = model.tools if model.images: entry["attachment"] = True entry["modalities"] = {"input": ["text", "image"], "output": ["text"]} - entry["limit"] = {"context": model.context_window, "output": OUTPUT_TOKENS} - models[model.key] = entry + entry["limit"] = {"context": model.context_window, "output": model.output_tokens} + entries[model.key] = entry return { "npm": "@ai-sdk/openai-compatible", "name": "Lightning AI", "options": {"baseURL": CODE_BASE_URL, "timeout": REQUEST_TIMEOUT_MS}, - "models": models, + "models": entries, } @@ -192,22 +187,25 @@ def config_diff(self) -> str: return "".join(difflib.unified_diff(before, after, fromfile=name, tofile=name)) -def plan_setup(state: State, *, model: Optional[str]) -> Plan: +def plan_setup(state: State, *, models: Sequence[CodingModel], model: Optional[str]) -> Plan: """Work out the new config: our provider block, and `model` only when asked or unset. ``model`` is a model key passed with --model; without it the default model is set only - when the user has no default of their own. + when the user has no default of their own, or has one on a Lightning model that's gone. """ text = state.config_text if not text.strip(): text = jsonc.set_value("", ["$schema"], SCHEMA_URL) - text = jsonc.set_value(text, ["provider", PROVIDER_ID], provider_config()) + text = jsonc.set_value(text, ["provider", PROVIDER_ID], provider_config(models)) + served = {model_ref(m.key) for m in models} + retired = isinstance(state.model, str) and state.model.startswith(f"{PROVIDER_ID}/") and state.model not in served set_model = None if model is not None: set_model = model_ref(model) - elif state.model is None: - set_model = model_ref(DEFAULT_MODEL) + elif state.model is None or retired: + # also replace a default on a Lightning model that is no longer served + set_model = model_ref(default_model(tuple(models))) if set_model is not None: text = jsonc.set_value(text, ["model"], set_model) return Plan(state, text, set_model) diff --git a/python/lightning_sdk/cli/code/setup.py b/python/lightning_sdk/cli/code/setup.py index a74cddfd7..c361edc83 100644 --- a/python/lightning_sdk/cli/code/setup.py +++ b/python/lightning_sdk/cli/code/setup.py @@ -5,7 +5,7 @@ import rich_click as click from lightning_sdk.cli.code import account, opencode -from lightning_sdk.cli.code.models import MODEL_KEYS +from lightning_sdk.cli.code.models import DEFAULT_MODEL, coding_models from lightning_sdk.cli.utils.logging import LightningCommand from lightning_sdk.utils.jsonc import JSONCError @@ -15,8 +15,10 @@ @click.option("--org", help="Organization that pays, by name. Asked for when you belong to several.") @click.option( "--model", - type=click.Choice(MODEL_KEYS), - help="Make this the default model. Without it, DeepSeek V4.1 Flash becomes the default only if you have none.", + help=( + f"Make this the default model, e.g. glm-5.3. Without it, {DEFAULT_MODEL} " + "becomes the default only if you have none." + ), ) @click.option("--rotate-key", is_flag=True, default=False, help="Create a new API key and revoke the old one.") @click.option("--dry-run", is_flag=True, default=False, help="Show the changes without writing or creating anything.") @@ -49,6 +51,13 @@ def setup(tool: str, org: Optional[str], model: Optional[str], rotate_key: bool, "Re-run with --force to replace it. The config file is backed up first." ) + models, fetch_error = coding_models() + if fetch_error: + click.echo(f"Couldn't get the model list from code.lightning.ai ({fetch_error}); using the built-in one.") + keys = [m.key for m in models] + if model is not None and model not in keys: + raise click.UsageError(f"Unknown model '{model}'. Choose one of: {', '.join(keys)}") + previous = state.credential if state.managed else None chosen = account.choose_org(org, previous_org_id=previous.org_id if previous else None) account.require_coding_plan(chosen) @@ -57,7 +66,7 @@ def setup(tool: str, org: Optional[str], model: Optional[str], rotate_key: bool, reuse = None if previous is not None and not rotate_key and previous.org_id == chosen.id and previous.key_id: reuse = previous if account.key_exists(chosen.id, previous.key_id) else None - plan = opencode.plan_setup(state, model=model) + plan = opencode.plan_setup(state, models=models, model=model) if dry_run: diff = plan.config_diff() diff --git a/python/tests/cli/code/models_response.json b/python/tests/cli/code/models_response.json new file mode 100644 index 000000000..188523f6e --- /dev/null +++ b/python/tests/cli/code/models_response.json @@ -0,0 +1,103 @@ +{ + "object": "list", + "data": [ + { + "id": "lightning-ai/glm-5.3", + "object": "model", + "created": 0, + "owned_by": "lightning-ai", + "name": "GLM-5.3", + "context_length": 1048576, + "max_model_len": 1048576, + "architecture": { + "modality": "text->text", + "input_modalities": [ + "text" + ], + "output_modalities": [ + "text" + ] + }, + "top_provider": { + "context_length": 1048576, + "max_completion_tokens": 131072, + "is_moderated": false + }, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "stop", + "tools", + "tool_choice", + "reasoning_effort" + ] + }, + { + "id": "lightning-ai/glm-5.3-flash", + "object": "model", + "created": 0, + "owned_by": "lightning-ai", + "name": "GLM-5.3 Flash", + "context_length": 253952, + "max_model_len": 253952, + "architecture": { + "modality": "text+image->text", + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "text" + ] + }, + "top_provider": { + "context_length": 253952, + "max_completion_tokens": 131072, + "is_moderated": false + }, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "stop", + "tools", + "tool_choice", + "reasoning_effort" + ] + }, + { + "id": "lightning-ai/deepseek-v4.1-flash", + "object": "model", + "created": 0, + "owned_by": "lightning-ai", + "name": "DeepSeek V4.1 Flash", + "context_length": 253952, + "max_model_len": 253952, + "architecture": { + "modality": "text+image->text", + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "text" + ] + }, + "top_provider": { + "context_length": 253952, + "max_completion_tokens": 131072, + "is_moderated": false + }, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "stop", + "tools", + "tool_choice", + "reasoning_effort" + ] + } + ] +} diff --git a/python/tests/cli/code/test_code.py b/python/tests/cli/code/test_code.py index cf37e8801..0e1801067 100644 --- a/python/tests/cli/code/test_code.py +++ b/python/tests/cli/code/test_code.py @@ -9,7 +9,8 @@ from click.testing import CliRunner, Result from lightning_sdk.cli.code import account, opencode -from lightning_sdk.cli.code.models import DEFAULT_MODEL +from lightning_sdk.cli.code import models as coding_models +from lightning_sdk.cli.code.models import DEFAULT_MODEL, FALLBACK_MODELS from lightning_sdk.cli.code.remove import remove from lightning_sdk.cli.code.setup import setup from lightning_sdk.cli.code.status import status @@ -37,6 +38,18 @@ def opencode_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return tmp_path +MODELS_RESPONSE = json.loads((Path(__file__).parent / "models_response.json").read_text()) + + +@pytest.fixture(autouse=True) +def models_api(): + """Serve /v1/models from a copy of what code.lightning.ai returned, instead of the network.""" + response = MagicMock() + response.json.return_value = MODELS_RESPONSE + with patch.object(coding_models.requests, "get", return_value=response) as get: + yield get + + def _org(plan="Enterprise", name="my-org", org_id="org-1", **kwargs) -> account.CodingOrg: fields = {"display_name": "", "personal": False, "coding_disabled": False, **kwargs} return account.CodingOrg(id=org_id, name=name, plan=plan, **fields) @@ -385,10 +398,106 @@ def test_unreadable_plan_is_allowed_with_a_note() -> None: def test_example_config_matches_what_setup_writes() -> None: example = Path(__file__).parents[3] / "examples" / "code" / "opencode.json" - plan = opencode.plan_setup(opencode.read_state(), model=None) + plan = opencode.plan_setup(opencode.read_state(), models=FALLBACK_MODELS, model=None) assert plan.config_text == example.read_text() def test_default_model_is_one_setup_configures() -> None: assert DEFAULT_MODEL == "deepseek-v4.1-flash" - assert DEFAULT_MODEL in opencode.provider_config()["models"] + assert DEFAULT_MODEL in opencode.provider_config(FALLBACK_MODELS)["models"] + assert DEFAULT_MODEL in opencode.provider_config(coding_models.fetch_models())["models"] + + +def test_fetch_models_reads_the_openrouter_fields(models_api) -> None: + models = {model.key: model for model in coding_models.fetch_models()} + + models_api.assert_called_once_with("https://code.lightning.ai/v1/models", timeout=10) + assert models["deepseek-v4.1-flash"] == coding_models.CodingModel( + key="deepseek-v4.1-flash", + id="lightning-ai/deepseek-v4.1-flash", + name="DeepSeek V4.1 Flash", + context_window=253_952, + output_tokens=131_072, + images=True, + ) + assert models["glm-5.3"].images is False + assert models["glm-5.3"].context_window == 1_048_576 + + +def test_fallback_matches_what_the_api_served() -> None: + assert coding_models.fetch_models() == FALLBACK_MODELS + + +def test_fetch_models_skips_entries_it_cannot_configure(models_api) -> None: + models_api.return_value.json.return_value = { + "data": [ + {"id": "lightning-ai/no-limits"}, + {"object": "model"}, + {"id": "lightning-ai/new", "context_length": 1000, "top_provider": {"max_completion_tokens": 100}}, + ] + } + [model] = coding_models.fetch_models() + assert (model.key, model.name, model.images, model.tools, model.reasoning) == ( + "new", + "lightning-ai/new", + False, + True, + True, + ) + + models_api.return_value.json.return_value = {"data": []} + with pytest.raises(ValueError, match="no models"): + coding_models.fetch_models() + + +def test_setup_writes_the_models_the_api_lists(api, models_api) -> None: + models_api.return_value.json.return_value = { + "data": [ + { + "id": "lightning-ai/next-model", + "name": "Next Model", + "context_length": 500_000, + "architecture": {"input_modalities": ["text"]}, + "top_provider": {"max_completion_tokens": 65_536}, + "supported_parameters": ["max_tokens"], + } + ] + } + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + + config = jsonc.loads(_config_path().read_text()) + assert config["provider"]["lightning"]["models"] == { + "next-model": { + "id": "lightning-ai/next-model", + "name": "Next Model", + "reasoning": False, + "tool_call": False, + "limit": {"context": 500_000, "output": 65_536}, + } + } + # the preferred default isn't served, so the first listed model is + assert config["model"] == "lightning/next-model" + + +def test_setup_falls_back_to_the_built_in_models(api, models_api) -> None: + models_api.side_effect = OSError("network down") + result = _invoke(setup, "opencode", "--org", "my-org") + + assert result.exit_code == 0 + assert "using the built-in one" in result.output + models = jsonc.loads(_config_path().read_text())["provider"]["lightning"]["models"] + assert set(models) == {model.key for model in FALLBACK_MODELS} + + +def test_setup_rejects_an_unknown_model(api) -> None: + result = CliRunner().invoke(setup, ["opencode", "--org", "my-org", "--model", "gpt-9"]) + assert result.exit_code == 2 + assert "Unknown model 'gpt-9'" in result.output + api.create_key.assert_not_called() + + +def test_setup_replaces_a_default_on_a_retired_model(api) -> None: + _config_path().parent.mkdir(parents=True) + _config_path().write_text('{"model": "lightning/glm-4"}') + assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert jsonc.loads(_config_path().read_text())["model"] == "lightning/deepseek-v4.1-flash"