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/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/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/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/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 32ecb36..cc95dfd 100644 --- a/packages/opencode-oauth2/src/opencode.ts +++ b/packages/opencode-oauth2/src/opencode.ts @@ -1,14 +1,46 @@ 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, + LOG_LEVEL_PRIORITY, + type Logger, + type LogLevel +} from "./logging.js"; import { OAuth2ModelSyncPlugin } from "./plugin.js"; +/** + * 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 +386,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 +433,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, @@ -404,6 +448,11 @@ export function createOpencodeOauth2Plugin( return { config: async (config) => { + // 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) { @@ -416,7 +465,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(); + }); }); 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": {