From e5865185b2bc910724352152c7fe1b9233a338a8 Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Tue, 26 May 2026 13:54:01 +0200 Subject: [PATCH 1/3] feat(logging): honor OpenCode's host logLevel + configurable level for standalone runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaults to "info". Inside OpenCode the plugin now reads the host's top-level `config.logLevel` ("DEBUG"|"INFO"|"WARN"|"ERROR") so a single host-level setting drives both console output and forwarded `app.log` records — no separate plugin knob to keep in sync. Standalone consumers of OAuth2ModelSyncPlugin can still pass `logLevel` via the runtime config (validated, defaults to "info"). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/opencode-oauth2/README.md | 2 + packages/opencode-oauth2/src/config.ts | 26 ++++++- packages/opencode-oauth2/src/lib.ts | 1 + packages/opencode-oauth2/src/opencode.ts | 67 ++++++++++++++++--- packages/opencode-oauth2/src/plugin.ts | 2 +- packages/opencode-oauth2/test/config.test.ts | 53 +++++++++++++++ .../opencode-oauth2/test/opencode.test.ts | 50 +++++++++++++- 7 files changed, 188 insertions(+), 13 deletions(-) diff --git a/packages/opencode-oauth2/README.md b/packages/opencode-oauth2/README.md index 5fc12af..04720aa 100644 --- a/packages/opencode-oauth2/README.md +++ b/packages/opencode-oauth2/README.md @@ -107,6 +107,8 @@ Plus the top-level `pluginConfig.oauth2ModelSync` block accepts: | `httpTimeoutMs` | `15000` | Timeout for token-endpoint / `/models` round trips. | | `tokenExpirySkewMs` | `30000` | Treat a token as expired this many ms before its real `expiresAt`. | +The plugin's log level follows the host's top-level `logLevel` (`"DEBUG" | "INFO" | "WARN" | "ERROR"`) — set it once in your OpenCode config and the plugin honors the same threshold for both console output and forwarded `app.log` records. Defaults to `"info"` when the host doesn't set one. + ## Federated identity (no long-lived secrets in CI) For CI runners and Kubernetes workloads, the modern best practice is to skip stored client secrets entirely and use the platform's own short-lived OIDC token to authenticate. `@vymalo/opencode-oauth2` supports this via the **`jwt_bearer`** (RFC 7523) and **`token_exchange`** (RFC 8693) grants. diff --git a/packages/opencode-oauth2/src/config.ts b/packages/opencode-oauth2/src/config.ts index afdf466..1c02cb7 100644 --- a/packages/opencode-oauth2/src/config.ts +++ b/packages/opencode-oauth2/src/config.ts @@ -1,6 +1,9 @@ +import type { LogLevel } from "./logging.js"; + export const DEFAULT_SYNC_INTERVAL_MINUTES = 60; export const DEFAULT_HTTP_TIMEOUT_MS = 15_000; export const DEFAULT_TOKEN_EXPIRY_SKEW_MS = 30_000; +export const DEFAULT_LOG_LEVEL: LogLevel = "info"; export type OAuthAuthFlow = | "authorization_code" @@ -71,6 +74,11 @@ export interface OAuth2ModelSyncConfigInput { cacheNamespace?: string; httpTimeoutMs?: number; tokenExpirySkewMs?: number; + /** + * Minimum log level the plugin emits. Lower-priority records are dropped. + * One of `"debug" | "info" | "warn" | "error"`. Defaults to `"info"`. + */ + logLevel?: LogLevel; } export interface OAuthServerConfig { @@ -98,6 +106,7 @@ export interface OAuth2ModelSyncConfig { cacheNamespace: string; httpTimeoutMs: number; tokenExpirySkewMs: number; + logLevel: LogLevel; } function ensureString(value: unknown, path: string): string { @@ -127,6 +136,20 @@ function validateRedirectPort(value: unknown, path: string): number | undefined return value; } +function validateLogLevel(value: unknown, path: string): LogLevel { + if (value === undefined || value === null) { + return DEFAULT_LOG_LEVEL; + } + + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + + throw new Error( + `${path} must be one of "debug" | "info" | "warn" | "error" (received ${JSON.stringify(value)})` + ); +} + function validateAuthFlow(value: unknown, path: string): OAuthAuthFlow { if (value === undefined || value === null) { return DEFAULT_AUTH_FLOW; @@ -314,6 +337,7 @@ export function validateConfig(input: OAuth2ModelSyncConfigInput): OAuth2ModelSy input.httpTimeoutMs > 0 ? input.httpTimeoutMs : DEFAULT_HTTP_TIMEOUT_MS, - tokenExpirySkewMs + tokenExpirySkewMs, + logLevel: validateLogLevel(input.logLevel, "logLevel") }; } diff --git a/packages/opencode-oauth2/src/lib.ts b/packages/opencode-oauth2/src/lib.ts index 484dfdc..47763f4 100644 --- a/packages/opencode-oauth2/src/lib.ts +++ b/packages/opencode-oauth2/src/lib.ts @@ -1,5 +1,6 @@ export { DEFAULT_HTTP_TIMEOUT_MS, + DEFAULT_LOG_LEVEL, DEFAULT_SYNC_INTERVAL_MINUTES, type OAuth2ModelSyncConfig, type OAuth2ModelSyncConfigInput, diff --git a/packages/opencode-oauth2/src/opencode.ts b/packages/opencode-oauth2/src/opencode.ts index 32ecb36..4ba5442 100644 --- a/packages/opencode-oauth2/src/opencode.ts +++ b/packages/opencode-oauth2/src/opencode.ts @@ -1,14 +1,47 @@ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; -import type { - OAuth2ModelSyncConfigInput, - OAuthAuthFlow, - OAuthServerConfigInput, - SubjectTokenSource +import { + DEFAULT_LOG_LEVEL, + type OAuth2ModelSyncConfigInput, + type OAuthAuthFlow, + type OAuthServerConfigInput, + type SubjectTokenSource } from "./config.js"; -import { createJsonConsoleLogger, type LogFields, type Logger } from "./logging.js"; +import { createJsonConsoleLogger, type LogFields, type Logger, type LogLevel } from "./logging.js"; import { OAuth2ModelSyncPlugin } from "./plugin.js"; +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; + +/** + * Map OpenCode's host-level `config.logLevel` (uppercase `"DEBUG" | "INFO" | + * "WARN" | "ERROR"`) to this plugin's internal `LogLevel`. Unknown / missing + * values fall through to `undefined` so the caller can apply its own default — + * we never throw on the OpenCode-supplied value because the host owns + * validation of its own field. + */ +export function fromOpenCodeLogLevel(value: unknown): LogLevel | undefined { + if (typeof value !== "string") { + return undefined; + } + switch (value.toUpperCase()) { + case "DEBUG": + return "debug"; + case "INFO": + return "info"; + case "WARN": + return "warn"; + case "ERROR": + return "error"; + default: + return undefined; + } +} + const OPENAI_COMPATIBLE_NPM = "@ai-sdk/openai-compatible"; const OAUTH_OPTIONS_KEYS = ["oauth2", "oauth2ModelSync"] as const; const PLUGIN_SERVICE_NAME = "opencode-oauth2-plugin"; @@ -354,10 +387,17 @@ function mergeDiscoveredModels( providerConfig.models = merged; } -function createOpenCodeLogger(client: PluginInput["client"]): Logger { - const fallback = createJsonConsoleLogger("info"); +function createOpenCodeLogger(client: PluginInput["client"], getMinLevel: () => LogLevel): Logger { + // Bypass createJsonConsoleLogger's own filter so the gate stays driven by + // the current value of getMinLevel() — the level can change once the plugin + // sees `pluginConfig.oauth2ModelSync.logLevel` during the `config` hook. + const fallback = createJsonConsoleLogger("debug"); const write = (level: "debug" | "info" | "warn" | "error", event: string, fields?: LogFields) => { + if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) { + return; + } + fallback[level](event, fields); void client.app @@ -394,7 +434,12 @@ export function createOpencodeOauth2Plugin( factoryOptions: OpenCodePluginFactoryOptions = {} ): Plugin { return async ({ client }) => { - const logger = factoryOptions.logger ?? createOpenCodeLogger(client); + // The plugin defers to OpenCode's own `config.logLevel` for filter + // decisions. Until the first `config` hook fires we don't know what the + // host picked, so we start at the package default (`"info"`) and update + // the holder once we see the real value. + let currentLogLevel: LogLevel = DEFAULT_LOG_LEVEL; + const logger = factoryOptions.logger ?? createOpenCodeLogger(client, () => currentLogLevel); const state: RuntimeState = { runtime: undefined, @@ -405,6 +450,7 @@ export function createOpencodeOauth2Plugin( return { config: async (config) => { const managed = collectManagedProviders(config, logger); + currentLogLevel = fromOpenCodeLogLevel(config.logLevel) ?? DEFAULT_LOG_LEVEL; if (managed.servers.length === 0) { state.runtime?.stop(); @@ -416,7 +462,8 @@ export function createOpencodeOauth2Plugin( const pluginConfig: OAuth2ModelSyncConfigInput = { servers: managed.servers, - cacheNamespace: "opencode-oauth2-model-sync" + cacheNamespace: "opencode-oauth2-model-sync", + logLevel: currentLogLevel }; const signature = runtimeSignature(pluginConfig); diff --git a/packages/opencode-oauth2/src/plugin.ts b/packages/opencode-oauth2/src/plugin.ts index 3baedd1..73f13b0 100644 --- a/packages/opencode-oauth2/src/plugin.ts +++ b/packages/opencode-oauth2/src/plugin.ts @@ -44,7 +44,7 @@ export class OAuth2ModelSyncPlugin { private readonly options: PluginOptions = {} ) { this.config = validateConfig(this.configInput); - this.logger = options.logger ?? createJsonConsoleLogger("info"); + this.logger = options.logger ?? createJsonConsoleLogger(this.config.logLevel); this.cacheStore = new FileCacheStore( options.cacheDir ?? resolveCacheDir(this.config.cacheNamespace) ); diff --git a/packages/opencode-oauth2/test/config.test.ts b/packages/opencode-oauth2/test/config.test.ts index 91575a4..afcb4ed 100644 --- a/packages/opencode-oauth2/test/config.test.ts +++ b/packages/opencode-oauth2/test/config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_HTTP_TIMEOUT_MS, + DEFAULT_LOG_LEVEL, DEFAULT_SYNC_INTERVAL_MINUTES, DEFAULT_TOKEN_EXPIRY_SKEW_MS, validateConfig @@ -384,6 +385,58 @@ describe("validateConfig", () => { ).toThrow(/audience/); }); + it("defaults logLevel to info when omitted", () => { + const result = validateConfig({ + servers: [ + { + id: "server-1", + issuer: "https://auth.example.com", + baseURL: "https://api.example.com/v1", + clientId: "client-id", + scopes: ["openid"] + } + ] + }); + + expect(result.logLevel).toBe(DEFAULT_LOG_LEVEL); + expect(result.logLevel).toBe("info"); + }); + + it("accepts a valid logLevel override", () => { + const result = validateConfig({ + logLevel: "debug", + servers: [ + { + id: "server-1", + issuer: "https://auth.example.com", + baseURL: "https://api.example.com/v1", + clientId: "client-id", + scopes: ["openid"] + } + ] + }); + + expect(result.logLevel).toBe("debug"); + }); + + it("rejects an unknown logLevel value", () => { + expect(() => + validateConfig({ + // biome-ignore lint/suspicious/noExplicitAny: testing invalid input + logLevel: "trace" as any, + servers: [ + { + id: "server-1", + issuer: "https://auth.example.com", + baseURL: "https://api.example.com/v1", + clientId: "client-id", + scopes: ["openid"] + } + ] + }) + ).toThrow(/logLevel/); + }); + it("rejects an unknown subjectTokenSource type", () => { expect(() => validateConfig({ diff --git a/packages/opencode-oauth2/test/opencode.test.ts b/packages/opencode-oauth2/test/opencode.test.ts index 0375186..c60aacd 100644 --- a/packages/opencode-oauth2/test/opencode.test.ts +++ b/packages/opencode-oauth2/test/opencode.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { FileCacheStore } from "../src/cache.js"; -import { createOpencodeOauth2Plugin } from "../src/opencode.js"; +import { createOpencodeOauth2Plugin, fromOpenCodeLogLevel } from "../src/opencode.js"; import { createSilentLogger } from "./helpers.js"; async function createHooks(cacheDir: string) { @@ -313,4 +313,52 @@ describe("OpenCode plugin hooks", () => { const options = providers["server-from-plugin-config"]?.options as Record; expect(options.baseURL).toBe("https://api.example.com/v1"); }); + + it("accepts an OpenCode host logLevel without requiring a plugin-specific override", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "opencode-hook-host-loglevel-")); + const hooks = await createHooks(cacheDir); + + const config: Record = { + logLevel: "DEBUG", + pluginConfig: { + oauth2ModelSync: { + servers: [ + { + id: "server-1", + issuer: "https://auth.example.com", + baseURL: "https://api.example.com/v1", + clientId: "opencode-client", + scopes: ["openid"] + } + ] + } + } + }; + + await expect(hooks.config?.(config as never)).resolves.not.toThrow(); + + const providers = config.provider as Record>; + expect(providers["server-1"]?.npm).toBe("@ai-sdk/openai-compatible"); + }); +}); + +describe("fromOpenCodeLogLevel", () => { + it("maps OpenCode's uppercase log levels to internal lowercase levels", () => { + expect(fromOpenCodeLogLevel("DEBUG")).toBe("debug"); + expect(fromOpenCodeLogLevel("INFO")).toBe("info"); + expect(fromOpenCodeLogLevel("WARN")).toBe("warn"); + expect(fromOpenCodeLogLevel("ERROR")).toBe("error"); + }); + + it("tolerates mixed-case input from non-canonical hosts", () => { + expect(fromOpenCodeLogLevel("debug")).toBe("debug"); + expect(fromOpenCodeLogLevel("Info")).toBe("info"); + }); + + it("returns undefined for missing or unrecognized values so the caller can apply a default", () => { + expect(fromOpenCodeLogLevel(undefined)).toBeUndefined(); + expect(fromOpenCodeLogLevel(null)).toBeUndefined(); + expect(fromOpenCodeLogLevel("trace")).toBeUndefined(); + expect(fromOpenCodeLogLevel(42)).toBeUndefined(); + }); }); From 8b0a54ab3710f79f7fedd667ce94593ba434c57e Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Tue, 26 May 2026 14:32:11 +0200 Subject: [PATCH 2/3] refactor(logging): apply host logLevel before config parsing + dedupe priority map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes from the PR: - Update `currentLogLevel` BEFORE calling `collectManagedProviders` so that warnings emitted during config parsing (plugin_config_server_invalid / plugin_config_server_missing_fields) are filtered against the user's chosen threshold rather than the bootstrap default. - Export `LOG_LEVEL_PRIORITY` from logging.ts and drop the duplicate copy in opencode.ts — single source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/opencode-oauth2/src/logging.ts | 2 +- packages/opencode-oauth2/src/opencode.ts | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/opencode-oauth2/src/logging.ts b/packages/opencode-oauth2/src/logging.ts index f2b0c62..2649ef0 100644 --- a/packages/opencode-oauth2/src/logging.ts +++ b/packages/opencode-oauth2/src/logging.ts @@ -1,6 +1,6 @@ export type LogLevel = "debug" | "info" | "warn" | "error"; -const LOG_LEVEL_PRIORITY: Record = { +export const LOG_LEVEL_PRIORITY: Record = { debug: 10, info: 20, warn: 30, diff --git a/packages/opencode-oauth2/src/opencode.ts b/packages/opencode-oauth2/src/opencode.ts index 4ba5442..cc95dfd 100644 --- a/packages/opencode-oauth2/src/opencode.ts +++ b/packages/opencode-oauth2/src/opencode.ts @@ -7,16 +7,15 @@ import { type OAuthServerConfigInput, type SubjectTokenSource } from "./config.js"; -import { createJsonConsoleLogger, type LogFields, type Logger, type LogLevel } from "./logging.js"; +import { + createJsonConsoleLogger, + type LogFields, + LOG_LEVEL_PRIORITY, + type Logger, + type LogLevel +} from "./logging.js"; import { OAuth2ModelSyncPlugin } from "./plugin.js"; -const LOG_LEVEL_PRIORITY: Record = { - debug: 10, - info: 20, - warn: 30, - error: 40 -}; - /** * Map OpenCode's host-level `config.logLevel` (uppercase `"DEBUG" | "INFO" | * "WARN" | "ERROR"`) to this plugin's internal `LogLevel`. Unknown / missing @@ -449,8 +448,12 @@ export function createOpencodeOauth2Plugin( return { config: async (config) => { - const managed = collectManagedProviders(config, logger); + // Apply the host's logLevel BEFORE walking the config: parsing emits + // `plugin_config_server_invalid` / `plugin_config_server_missing_fields` + // warnings via `logger`, and those need to be filtered against the + // user's chosen threshold — not the bootstrap default. currentLogLevel = fromOpenCodeLogLevel(config.logLevel) ?? DEFAULT_LOG_LEVEL; + const managed = collectManagedProviders(config, logger); if (managed.servers.length === 0) { state.runtime?.stop(); From 3f86257024e0c22a50478d995e20d054d715a35a Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Tue, 26 May 2026 15:28:43 +0200 Subject: [PATCH 3/3] chore: bump version to 0.3.0 Minor bump (pre-1.0) for the logLevel feature: plugin now honors OpenCode's host logLevel, and standalone runtime gains a validated `logLevel` field in OAuth2ModelSyncConfigInput. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- packages/opencode-oauth2/package.json | 2 +- packages/plugin-bundle/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index fe8de04..2a54bdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-oauth2-workspace", - "version": "0.2.1", + "version": "0.3.0", "private": true, "description": "Workspace for @vymalo/opencode-oauth2 — OAuth2/OIDC-secured OpenAI-compatible providers for OpenCode.", "packageManager": "pnpm@11.3.0", diff --git a/packages/opencode-oauth2/package.json b/packages/opencode-oauth2/package.json index 8c3bd1c..61a2367 100644 --- a/packages/opencode-oauth2/package.json +++ b/packages/opencode-oauth2/package.json @@ -1,6 +1,6 @@ { "name": "@vymalo/opencode-oauth2", - "version": "0.2.1", + "version": "0.3.0", "description": "OpenCode plugin for OAuth2/OIDC-secured, OpenAI-compatible model providers with periodic model sync.", "license": "MIT", "author": "vymalo contributors", diff --git a/packages/plugin-bundle/package.json b/packages/plugin-bundle/package.json index 27dd9b8..3b189aa 100644 --- a/packages/plugin-bundle/package.json +++ b/packages/plugin-bundle/package.json @@ -1,6 +1,6 @@ { "name": "@vymalo/opencode-oauth2-bundle", - "version": "0.2.1", + "version": "0.3.0", "private": true, "type": "module", "scripts": {