refactor: move all server config to startup env vars; drop plugin settings
This commit is contained in:
+23
-16
@@ -1,18 +1,25 @@
|
|||||||
"""Configuration resolution for the opencode-bridge extension.
|
"""Configuration resolution for the opencode-bridge extension.
|
||||||
|
|
||||||
Priority order (highest to lowest):
|
All connection fields (URL / user / password / request timeout) are resolved
|
||||||
1. jupyter settings dict (from schema/plugin.json)
|
exclusively from environment variables + built-in defaults at server startup.
|
||||||
2. Environment variables (OPENCODE_BRIDGE_URL, _USER, _PASSWORD)
|
They are NOT stored in the JupyterLab plugin settings.
|
||||||
3. Built-in defaults
|
|
||||||
|
Env vars:
|
||||||
|
OPENCODE_BRIDGE_URL — OpenCode Serve base URL (default http://127.0.0.1:4096)
|
||||||
|
OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode')
|
||||||
|
OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth)
|
||||||
|
OPENCODE_BRIDGE_TIMEOUT — request timeout in seconds (default 120)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import NamedTuple, Optional, Tuple
|
from typing import NamedTuple, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
ENV_URL = "OPENCODE_BRIDGE_URL"
|
ENV_URL = "OPENCODE_BRIDGE_URL"
|
||||||
ENV_USER = "OPENCODE_BRIDGE_USER"
|
ENV_USER = "OPENCODE_BRIDGE_USER"
|
||||||
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
|
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
|
||||||
|
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"
|
||||||
|
|
||||||
DEFAULT_URL = "http://127.0.0.1:4096"
|
DEFAULT_URL = "http://127.0.0.1:4096"
|
||||||
DEFAULT_USER = "opencode"
|
DEFAULT_USER = "opencode"
|
||||||
@@ -23,7 +30,7 @@ class OpenCodeConfig(NamedTuple):
|
|||||||
url: str
|
url: str
|
||||||
user: str
|
user: str
|
||||||
password: str
|
password: str
|
||||||
request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT
|
request_timeout_seconds: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth(self) -> Optional[Tuple[str, str]]:
|
def auth(self) -> Optional[Tuple[str, str]]:
|
||||||
@@ -34,18 +41,18 @@ class OpenCodeConfig(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
||||||
"""Resolve OpenCode connection config from jupyter settings + env + defaults."""
|
"""Resolve OpenCode connection config from environment variables + defaults.
|
||||||
bridge = jupyter_settings.get("opencode_bridge", {}) or {}
|
|
||||||
|
|
||||||
|
The `jupyter_settings` argument is accepted for API compatibility but no
|
||||||
|
fields are read from it — all connection config is now startup-only
|
||||||
|
(environment variables).
|
||||||
|
"""
|
||||||
|
_ = jupyter_settings # intentionally unused; see module docstring
|
||||||
return OpenCodeConfig(
|
return OpenCodeConfig(
|
||||||
url=bridge.get("opencodeServerUrl") or os.environ.get(ENV_URL) or DEFAULT_URL,
|
url=os.environ.get(ENV_URL) or DEFAULT_URL,
|
||||||
user=bridge.get("opencodeServerUser") or os.environ.get(ENV_USER) or DEFAULT_USER,
|
user=os.environ.get(ENV_USER) or DEFAULT_USER,
|
||||||
password=(
|
password=os.environ.get(ENV_PASSWORD) or "",
|
||||||
bridge.get("opencodeServerPassword")
|
|
||||||
or os.environ.get(ENV_PASSWORD)
|
|
||||||
or ""
|
|
||||||
),
|
|
||||||
request_timeout_seconds=int(
|
request_timeout_seconds=int(
|
||||||
bridge.get("requestTimeoutSeconds", DEFAULT_REQUEST_TIMEOUT)
|
os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_message_sync_includes_system_when_provided() -> None:
|
async def test_send_message_sync_includes_system_when_provided() -> None:
|
||||||
"""system param is forwarded into the request body when not None."""
|
"""system param is forwarded into the request body when not None."""
|
||||||
config = OpenCodeConfig(url="http://x:1", user="u", password="")
|
config = OpenCodeConfig(
|
||||||
|
url="http://x:1", user="u", password="", request_timeout_seconds=120
|
||||||
|
)
|
||||||
mock = MockHTTPClient()
|
mock = MockHTTPClient()
|
||||||
mock.responses.append((200, {"info": {}, "parts": []}))
|
mock.responses.append((200, {"info": {}, "parts": []}))
|
||||||
client = OpenCodeClient(config, http_client=mock)
|
client = OpenCodeClient(config, http_client=mock)
|
||||||
|
|||||||
@@ -17,21 +17,23 @@ def test_env_url_overrides_default(monkeypatch) -> None:
|
|||||||
assert config.url == "http://x:1"
|
assert config.url == "http://x:1"
|
||||||
|
|
||||||
|
|
||||||
def test_jupyter_settings_override_env(monkeypatch) -> None:
|
def test_jupyter_settings_are_ignored(monkeypatch) -> None:
|
||||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||||
settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}}
|
settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}}
|
||||||
config = resolve_config(settings)
|
config = resolve_config(settings)
|
||||||
assert config.url == "http://y:2"
|
assert config.url == "http://x:1"
|
||||||
|
|
||||||
|
|
||||||
def test_all_env_vars(monkeypatch) -> None:
|
def test_all_env_vars(monkeypatch) -> None:
|
||||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||||
monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u")
|
monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u")
|
||||||
monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p")
|
monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p")
|
||||||
|
monkeypatch.setenv("OPENCODE_BRIDGE_TIMEOUT", "300")
|
||||||
config = resolve_config({})
|
config = resolve_config({})
|
||||||
assert config.url == "http://x:1"
|
assert config.url == "http://x:1"
|
||||||
assert config.user == "u"
|
assert config.user == "u"
|
||||||
assert config.password == "p"
|
assert config.password == "p"
|
||||||
|
assert config.request_timeout_seconds == 300
|
||||||
|
|
||||||
|
|
||||||
def test_auth_none_when_password_empty() -> None:
|
def test_auth_none_when_password_empty() -> None:
|
||||||
@@ -44,11 +46,12 @@ def test_auth_when_password_set() -> None:
|
|||||||
url="http://127.0.0.1:4096",
|
url="http://127.0.0.1:4096",
|
||||||
user="opencode",
|
user="opencode",
|
||||||
password="secret",
|
password="secret",
|
||||||
|
request_timeout_seconds=120,
|
||||||
)
|
)
|
||||||
assert config.auth == ("opencode", "secret")
|
assert config.auth == ("opencode", "secret")
|
||||||
|
|
||||||
|
|
||||||
def test_request_timeout_from_settings() -> None:
|
def test_request_timeout_from_env(monkeypatch) -> None:
|
||||||
settings = {"opencode_bridge": {"requestTimeoutSeconds": 300}}
|
monkeypatch.setenv("OPENCODE_BRIDGE_TIMEOUT", "300")
|
||||||
config = resolve_config(settings)
|
config = resolve_config({})
|
||||||
assert config.request_timeout_seconds == 300
|
assert config.request_timeout_seconds == 300
|
||||||
|
|||||||
+1
-39
@@ -6,44 +6,6 @@
|
|||||||
"title": "opencode_bridge",
|
"title": "opencode_bridge",
|
||||||
"description": "opencode_bridge settings.",
|
"description": "opencode_bridge settings.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {},
|
||||||
"opencodeServerUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Server URL",
|
|
||||||
"description": "opencode serve 监听的 HTTP 地址。覆盖 OPENCODE_BRIDGE_URL 环境变量。",
|
|
||||||
"default": "http://127.0.0.1:4096"
|
|
||||||
},
|
|
||||||
"opencodeServerUser": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Server 用户名",
|
|
||||||
"description": "HTTP Basic Auth 用户名。默认 'opencode'。可被 OPENCODE_BRIDGE_USER 环境变量覆盖。",
|
|
||||||
"default": "opencode"
|
|
||||||
},
|
|
||||||
"opencodeServerPassword": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Server 密码",
|
|
||||||
"description": "HTTP Basic Auth 密码。空字符串表示无认证。可被 OPENCODE_BRIDGE_PASSWORD 环境变量覆盖。",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"opencodeProvider": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Provider",
|
|
||||||
"description": "Provider id (e.g. 'anthropic' or 'openai'). Get available values from the browser console after activating this extension (logged on startup), or hit GET /opencode-bridge/providers directly. Leave empty to use OpenCode's default.",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"opencodeModel": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Model",
|
|
||||||
"description": "Model id (e.g. 'claude-sonnet-4-20250514'). Get available values from the browser console after activation. Leave empty to use the provider's default.",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"requestTimeoutSeconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "请求超时(秒)",
|
|
||||||
"default": 120,
|
|
||||||
"minimum": 5,
|
|
||||||
"maximum": 600
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { DEFAULT_OPENCODE_SETTINGS, readOpenCodeSettings } from '../types';
|
|
||||||
|
|
||||||
describe('opencode_bridge types', () => {
|
|
||||||
it('returns defaults when given empty composite', () => {
|
|
||||||
const s = readOpenCodeSettings({});
|
|
||||||
expect(s).toEqual(DEFAULT_OPENCODE_SETTINGS);
|
|
||||||
expect(s.opencodeProvider).toBe('');
|
|
||||||
expect(s.opencodeModel).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('overrides defaults with user values', () => {
|
|
||||||
const s = readOpenCodeSettings({
|
|
||||||
opencodeServerUrl: 'http://x:1',
|
|
||||||
opencodeProvider: 'anthropic',
|
|
||||||
opencodeModel: 'claude-sonnet-4-20250514'
|
|
||||||
});
|
|
||||||
expect(s.opencodeServerUrl).toBe('http://x:1');
|
|
||||||
expect(s.opencodeProvider).toBe('anthropic');
|
|
||||||
expect(s.opencodeModel).toBe('claude-sonnet-4-20250514');
|
|
||||||
expect(s.opencodeServerUser).toBe(
|
|
||||||
DEFAULT_OPENCODE_SETTINGS.opencodeServerUser
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats empty string provider/model as "use default"', () => {
|
|
||||||
const s = readOpenCodeSettings({
|
|
||||||
opencodeProvider: '',
|
|
||||||
opencodeModel: ''
|
|
||||||
});
|
|
||||||
expect(s.opencodeProvider).toBe('');
|
|
||||||
expect(s.opencodeModel).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+17
-27
@@ -5,32 +5,38 @@ import {
|
|||||||
|
|
||||||
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
||||||
import { Cell, CodeCell } from '@jupyterlab/cells';
|
import { Cell, CodeCell } from '@jupyterlab/cells';
|
||||||
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
OpenCodeCellActions,
|
OpenCodeCellActions,
|
||||||
setOpenCodeRuntime
|
setOpenCodeProviders,
|
||||||
|
setOpenCodeServerSettings
|
||||||
} from './components/opencode_cell_actions';
|
} from './components/opencode_cell_actions';
|
||||||
import { requestAPI } from './request';
|
import { requestAPI } from './request';
|
||||||
import { readOpenCodeSettings } from './types';
|
|
||||||
import { callOpenCodeProviders } from './api/opencode_client';
|
import { callOpenCodeProviders } from './api/opencode_client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialization data for the opencode_bridge extension.
|
* Initialization data for the opencode_bridge extension.
|
||||||
|
*
|
||||||
|
* v3-final: no JupyterLab plugin settings. Connection config is read from
|
||||||
|
* environment variables by the server extension; the model is picked
|
||||||
|
* dynamically in the inline prompt.
|
||||||
*/
|
*/
|
||||||
const plugin: JupyterFrontEndPlugin<void> = {
|
const plugin: JupyterFrontEndPlugin<void> = {
|
||||||
id: 'opencode_bridge:plugin',
|
id: 'opencode_bridge:plugin',
|
||||||
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
optional: [ISettingRegistry, IToolbarWidgetRegistry],
|
optional: [IToolbarWidgetRegistry],
|
||||||
activate: (
|
activate: (
|
||||||
app: JupyterFrontEnd,
|
app: JupyterFrontEnd,
|
||||||
settingRegistry: ISettingRegistry | null,
|
|
||||||
toolbarRegistry: IToolbarWidgetRegistry | null
|
toolbarRegistry: IToolbarWidgetRegistry | null
|
||||||
) => {
|
) => {
|
||||||
console.log('JupyterLab extension opencode_bridge is activated!');
|
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||||
|
|
||||||
|
// Push the Jupyter server settings into the actions module so that
|
||||||
|
// callOpenCodeEdit can build the right base URL.
|
||||||
|
setOpenCodeServerSettings(app.serviceManager.serverSettings);
|
||||||
|
|
||||||
// Register the per-cell AI actions into the native Cell toolbar
|
// Register the per-cell AI actions into the native Cell toolbar
|
||||||
// (top-right of the active cell, next to move up/down). Non-code
|
// (top-right of the active cell, next to move up/down). Non-code
|
||||||
// cells get an empty widget so they show no AI buttons.
|
// cells get an empty widget so they show no AI buttons.
|
||||||
@@ -47,24 +53,11 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load settings + push into the actions module.
|
// Fetch available providers once at activation and cache them for the
|
||||||
if (settingRegistry) {
|
// inline prompt's model picker. Failure is non-fatal (the picker hides).
|
||||||
void settingRegistry
|
|
||||||
.load(plugin.id)
|
|
||||||
.then(settings => {
|
|
||||||
const apply = () => {
|
|
||||||
const bridge = readOpenCodeSettings(settings.composite);
|
|
||||||
setOpenCodeRuntime({
|
|
||||||
settings: bridge,
|
|
||||||
serverSettings: app.serviceManager.serverSettings
|
|
||||||
});
|
|
||||||
console.log('opencode_bridge settings applied:', bridge);
|
|
||||||
};
|
|
||||||
apply();
|
|
||||||
settings.changed.connect(apply);
|
|
||||||
|
|
||||||
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
setOpenCodeProviders(data);
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'[opencode_bridge] Available OpenCode providers:'
|
'[opencode_bridge] Available OpenCode providers:'
|
||||||
];
|
];
|
||||||
@@ -72,19 +65,16 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|||||||
const models = p.models.map(m => m.id).join(', ');
|
const models = p.models.map(m => m.id).join(', ');
|
||||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.log(lines.join('\n'));
|
console.log(lines.join('\n'));
|
||||||
})
|
})
|
||||||
.catch(reason => {
|
.catch(reason => {
|
||||||
console.warn(
|
// eslint-disable-next-line no-console
|
||||||
|
+ console.warn(
|
||||||
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
||||||
reason
|
reason
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.catch(reason => {
|
|
||||||
console.error('Failed to load settings for opencode_bridge.', reason);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
|
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
|||||||
+4
-34
@@ -3,6 +3,10 @@
|
|||||||
*
|
*
|
||||||
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
|
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
|
||||||
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
|
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
|
||||||
|
*
|
||||||
|
* v3-final: the JupyterLab plugin settings have been removed entirely.
|
||||||
|
* All connection config lives in startup environment variables (see
|
||||||
|
* `opencode_bridge/config.py`); the inline model picker is fully dynamic.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ErrorOutput {
|
export interface ErrorOutput {
|
||||||
@@ -43,40 +47,6 @@ export interface OpenCodeFailure {
|
|||||||
|
|
||||||
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
|
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
|
||||||
|
|
||||||
/** Frontend view of the 4 schema/plugin.json fields. */
|
|
||||||
export interface OpenCodeSettings {
|
|
||||||
opencodeServerUrl: string;
|
|
||||||
opencodeServerUser: string;
|
|
||||||
opencodeServerPassword: string;
|
|
||||||
requestTimeoutSeconds: number;
|
|
||||||
opencodeProvider: string;
|
|
||||||
opencodeModel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = {
|
|
||||||
opencodeServerUrl: 'http://127.0.0.1:4096',
|
|
||||||
opencodeServerUser: 'opencode',
|
|
||||||
opencodeServerPassword: '',
|
|
||||||
requestTimeoutSeconds: 120,
|
|
||||||
opencodeProvider: '',
|
|
||||||
opencodeModel: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function readOpenCodeSettings(composite: unknown): OpenCodeSettings {
|
|
||||||
const c = (composite ?? {}) as Partial<OpenCodeSettings>;
|
|
||||||
return {
|
|
||||||
opencodeServerUrl: c.opencodeServerUrl || DEFAULT_OPENCODE_SETTINGS.opencodeServerUrl,
|
|
||||||
opencodeServerUser: c.opencodeServerUser || DEFAULT_OPENCODE_SETTINGS.opencodeServerUser,
|
|
||||||
opencodeServerPassword: c.opencodeServerPassword || '',
|
|
||||||
requestTimeoutSeconds:
|
|
||||||
typeof c.requestTimeoutSeconds === 'number'
|
|
||||||
? c.requestTimeoutSeconds
|
|
||||||
: DEFAULT_OPENCODE_SETTINGS.requestTimeoutSeconds,
|
|
||||||
opencodeProvider: c.opencodeProvider || '',
|
|
||||||
opencodeModel: c.opencodeModel || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response from GET /opencode-bridge/providers. */
|
/** Response from GET /opencode-bridge/providers. */
|
||||||
export interface OpenCodeProvider {
|
export interface OpenCodeProvider {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user