Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode-oauth2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode-oauth2/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
26 changes: 25 additions & 1 deletion packages/opencode-oauth2/src/config.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -98,6 +106,7 @@ export interface OAuth2ModelSyncConfig {
cacheNamespace: string;
httpTimeoutMs: number;
tokenExpirySkewMs: number;
logLevel: LogLevel;
}

function ensureString(value: unknown, path: string): string {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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")
};
}
1 change: 1 addition & 0 deletions packages/opencode-oauth2/src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
DEFAULT_HTTP_TIMEOUT_MS,
DEFAULT_LOG_LEVEL,
DEFAULT_SYNC_INTERVAL_MINUTES,
type OAuth2ModelSyncConfig,
type OAuth2ModelSyncConfigInput,
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode-oauth2/src/logging.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type LogLevel = "debug" | "info" | "warn" | "error";

const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
export const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
Expand Down
70 changes: 60 additions & 10 deletions packages/opencode-oauth2/src/opencode.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode-oauth2/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down
53 changes: 53 additions & 0 deletions packages/opencode-oauth2/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
50 changes: 49 additions & 1 deletion packages/opencode-oauth2/test/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -313,4 +313,52 @@ describe("OpenCode plugin hooks", () => {
const options = providers["server-from-plugin-config"]?.options as Record<string, unknown>;
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<string, unknown> = {
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<string, Record<string, unknown>>;
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();
});
});
2 changes: 1 addition & 1 deletion packages/plugin-bundle/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vymalo/opencode-oauth2-bundle",
"version": "0.2.1",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down