From dcefcb7e555bfc03a62cbd64326fb781e4a58475 Mon Sep 17 00:00:00 2001 From: Sergej Popov Date: Tue, 28 Apr 2026 15:47:01 +0100 Subject: [PATCH 1/2] feat: basic auth support; built with OpenCode + GPT5.4; --- credentials/OpenCodeApi.credentials.ts | 104 ++++++++++++--- jest.config.cjs | 7 + nodes/LmChatOpenCode/LmChatOpenCode.node.ts | 31 ++++- nodes/LmChatOpenCode/OpenCodeChatModel.ts | 40 ++++-- nodes/LmChatOpenCode/auth.ts | 45 +++++++ tests/OpenCodeChatModel.test.ts | 103 +++++++++++++- tests/auth.test.ts | 45 +++++++ tests/integration.test.ts | 141 ++++++++++++++++---- 8 files changed, 447 insertions(+), 69 deletions(-) create mode 100644 jest.config.cjs create mode 100644 nodes/LmChatOpenCode/auth.ts create mode 100644 tests/auth.test.ts diff --git a/credentials/OpenCodeApi.credentials.ts b/credentials/OpenCodeApi.credentials.ts index 76bfe7e..cb5e4f2 100644 --- a/credentials/OpenCodeApi.credentials.ts +++ b/credentials/OpenCodeApi.credentials.ts @@ -1,7 +1,8 @@ import type { - IAuthenticateGeneric, + ICredentialDataDecryptedObject, ICredentialTestRequest, ICredentialType, + IHttpRequestOptions, INodeProperties, } from "n8n-workflow"; @@ -21,40 +22,103 @@ export class OpenCodeApi implements ICredentialType { description: "The base URL of your OpenCode server instance", placeholder: "http://127.0.0.1:4096", }, + { + displayName: "Authentication", + name: "authType", + type: "options", + default: "none", + options: [ + { + name: "None", + value: "none", + }, + { + name: "Basic Auth", + value: "basic", + }, + { + name: "Bearer Token", + value: "bearer", + }, + ], + description: "How to authenticate against the OpenCode server", + }, + { + displayName: "Username", + name: "username", + type: "string", + default: "opencode", + displayOptions: { + show: { + authType: ["basic"], + }, + }, + description: "Username for OpenCode Basic auth", + }, + { + displayName: "Password", + name: "password", + type: "string", + typeOptions: { password: true }, + default: "", + displayOptions: { + show: { + authType: ["basic"], + }, + }, + description: "Password for OpenCode Basic auth", + }, { displayName: "API Key", name: "apiKey", type: "string", typeOptions: { password: true }, default: "", - description: "Optional API key for authenticated OpenCode instances", + displayOptions: { + show: { + authType: ["bearer"], + }, + }, + description: "Bearer token for OpenCode instances using token auth", }, ]; - authenticate: IAuthenticateGeneric = { - type: "generic", - properties: { - headers: { - Authorization: - '={{$credentials.apiKey ? "Bearer " + $credentials.apiKey : ""}}', - }, - }, + authenticate = async ( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise => { + const authType = credentials.authType as string | undefined; + + if (authType === "basic" && credentials.password) { + return { + ...requestOptions, + auth: { + username: (credentials.username as string) || "opencode", + password: credentials.password as string, + }, + }; + } + + if (authType === "bearer" && credentials.apiKey) { + return { + ...requestOptions, + headers: { + ...(requestOptions.headers || {}), + Authorization: `Bearer ${credentials.apiKey as string}`, + }, + }; + } + + return requestOptions; }; test: ICredentialTestRequest = { request: { baseURL: "={{$credentials.baseUrl}}", - url: "/session", - method: "POST", + url: "/global/health", + method: "GET", headers: { - "Content-Type": "application/json", - }, - body: { - agent: "coder", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4", - }, + Accept: "application/json", }, }, }; diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..641e5f2 --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/tests/**/*.test.ts"], + modulePathIgnorePatterns: ["/dist/"], + clearMocks: true, +}; diff --git a/nodes/LmChatOpenCode/LmChatOpenCode.node.ts b/nodes/LmChatOpenCode/LmChatOpenCode.node.ts index 6b17a83..4e6e7d6 100644 --- a/nodes/LmChatOpenCode/LmChatOpenCode.node.ts +++ b/nodes/LmChatOpenCode/LmChatOpenCode.node.ts @@ -8,6 +8,7 @@ import type { } from "n8n-workflow"; import { NodeConnectionTypes } from "n8n-workflow"; import { OpenCodeChatModel } from "./OpenCodeChatModel"; +import { getOpenCodeAuthHeaders } from "./auth"; export class LmChatOpenCode implements INodeType { description: INodeTypeDescription = { @@ -128,13 +129,18 @@ export class LmChatOpenCode implements INodeType { const credentials = await this.getCredentials("openCodeApi"); const baseUrl = (credentials?.baseUrl as string) || "http://127.0.0.1:4096"; - const apiKey = credentials?.apiKey as string | undefined; + const headers = getOpenCodeAuthHeaders({ + authType: credentials?.authType as string | undefined, + apiKey: credentials?.apiKey as string | undefined, + username: credentials?.username as string | undefined, + password: credentials?.password as string | undefined, + }); try { const response = await this.helpers.httpRequest({ method: "GET", url: `${baseUrl}/config/providers`, - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + headers, }); // Transform providers array to options @@ -166,13 +172,18 @@ export class LmChatOpenCode implements INodeType { const credentials = await this.getCredentials("openCodeApi"); const baseUrl = (credentials?.baseUrl as string) || "http://127.0.0.1:4096"; - const apiKey = credentials?.apiKey as string | undefined; + const headers = getOpenCodeAuthHeaders({ + authType: credentials?.authType as string | undefined, + apiKey: credentials?.apiKey as string | undefined, + username: credentials?.username as string | undefined, + password: credentials?.password as string | undefined, + }); try { const response = await this.helpers.httpRequest({ method: "GET", url: `${baseUrl}/agent`, - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + headers, }); // Transform agents array to options @@ -205,7 +216,12 @@ export class LmChatOpenCode implements INodeType { const credentials = await this.getCredentials("openCodeApi"); const baseUrl = (credentials?.baseUrl as string) || "http://127.0.0.1:4096"; - const apiKey = credentials?.apiKey as string | undefined; + const headers = getOpenCodeAuthHeaders({ + authType: credentials?.authType as string | undefined, + apiKey: credentials?.apiKey as string | undefined, + username: credentials?.username as string | undefined, + password: credentials?.password as string | undefined, + }); const providerID = this.getCurrentNodeParameter("providerID") as string; if (!providerID) { @@ -216,7 +232,7 @@ export class LmChatOpenCode implements INodeType { const response = await this.helpers.httpRequest({ method: "GET", url: `${baseUrl}/config/providers`, - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + headers, }); // Find provider in array and get models object @@ -264,7 +280,10 @@ export class LmChatOpenCode implements INodeType { const model = new OpenCodeChatModel({ baseUrl, + authType: credentials?.authType as string | undefined, apiKey: credentials?.apiKey as string | undefined, + username: credentials?.username as string | undefined, + password: credentials?.password as string | undefined, agent, providerID, modelID, diff --git a/nodes/LmChatOpenCode/OpenCodeChatModel.ts b/nodes/LmChatOpenCode/OpenCodeChatModel.ts index 68bafb8..8c261cf 100644 --- a/nodes/LmChatOpenCode/OpenCodeChatModel.ts +++ b/nodes/LmChatOpenCode/OpenCodeChatModel.ts @@ -7,10 +7,14 @@ import { CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; import { BaseMessage, AIMessage } from "@langchain/core/messages"; import { ChatResult } from "@langchain/core/outputs"; import type { Runnable } from "@langchain/core/runnables"; +import { getOpenCodeAuthHeaders } from "./auth"; export interface OpenCodeChatModelInput extends BaseChatModelParams { baseUrl?: string; + authType?: string; apiKey?: string; + username?: string; + password?: string; agent?: string; providerID?: string; modelID?: string; @@ -38,7 +42,10 @@ interface OpenCodeMessageResponse { export class OpenCodeChatModel extends BaseChatModel { baseUrl = "http://127.0.0.1:4096"; + authType = "none"; apiKey?: string; + username?: string; + password?: string; agent = "build"; providerID = "anthropic"; modelID = "claude-3-5-sonnet-20241022"; @@ -69,7 +76,10 @@ export class OpenCodeChatModel extends BaseChatModel { throw new Error("modelID is required and cannot be empty"); } + this.authType = fields.authType ?? this.authType; this.apiKey = fields.apiKey; + this.username = fields.username; + this.password = fields.password; this.agent = fields.agent ?? this.agent; this.providerID = providerID; this.modelID = modelID; @@ -142,12 +152,14 @@ export class OpenCodeChatModel extends BaseChatModel { private async createSession(): Promise { const headers: Record = { "Content-Type": "application/json", + ...getOpenCodeAuthHeaders({ + authType: this.authType, + apiKey: this.apiKey, + username: this.username, + password: this.password, + }), }; - if (this.apiKey) { - headers["Authorization"] = `Bearer ${this.apiKey}`; - } - const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout); @@ -234,12 +246,14 @@ export class OpenCodeChatModel extends BaseChatModel { ): Promise { const headers: Record = { "Content-Type": "application/json", + ...getOpenCodeAuthHeaders({ + authType: this.authType, + apiKey: this.apiKey, + username: this.username, + password: this.password, + }), }; - if (this.apiKey) { - headers["Authorization"] = `Bearer ${this.apiKey}`; - } - const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout); @@ -318,10 +332,12 @@ export class OpenCodeChatModel extends BaseChatModel { private async deleteSession(sessionId: string): Promise { try { - const headers: Record = {}; - if (this.apiKey) { - headers["Authorization"] = `Bearer ${this.apiKey}`; - } + const headers = getOpenCodeAuthHeaders({ + authType: this.authType, + apiKey: this.apiKey, + username: this.username, + password: this.password, + }); const controller = new AbortController(); const timeoutId = setTimeout( diff --git a/nodes/LmChatOpenCode/auth.ts b/nodes/LmChatOpenCode/auth.ts new file mode 100644 index 0000000..ce72a76 --- /dev/null +++ b/nodes/LmChatOpenCode/auth.ts @@ -0,0 +1,45 @@ +type OpenCodeCredentialsLike = { + authType?: string; + apiKey?: string; + username?: string; + password?: string; +}; + +export function getOpenCodeAuthHeaders( + credentials?: OpenCodeCredentialsLike, +): Record { + if (!credentials) { + return {}; + } + + const authType = credentials.authType ?? "none"; + + if (authType === "basic") { + const password = credentials.password?.trim(); + if (!password) { + return {}; + } + + const username = credentials.username?.trim() || "opencode"; + const token = Buffer.from(`${username}:${password}`, "utf8").toString( + "base64", + ); + + return { + Authorization: `Basic ${token}`, + }; + } + + if (authType === "bearer") { + const apiKey = credentials.apiKey?.trim(); + if (!apiKey) { + return {}; + } + + return { + Authorization: `Bearer ${apiKey}`, + }; + } + + return {}; +} diff --git a/tests/OpenCodeChatModel.test.ts b/tests/OpenCodeChatModel.test.ts index acbde7a..93b3f9b 100644 --- a/tests/OpenCodeChatModel.test.ts +++ b/tests/OpenCodeChatModel.test.ts @@ -1,9 +1,12 @@ import { OpenCodeChatModel } from "../nodes/LmChatOpenCode/OpenCodeChatModel"; import { HumanMessage, AIMessage } from "@langchain/core/messages"; +const basicAuthHeader = `Basic ${Buffer.from("opencode:changeme", "utf8").toString("base64")}`; + describe("OpenCodeChatModel", () => { let model: OpenCodeChatModel; let mockFetch: jest.Mock; + let warnSpy: jest.SpyInstance; beforeEach(() => { // Mock global fetch @@ -16,10 +19,13 @@ describe("OpenCodeChatModel", () => { modelID: "claude-3-5-sonnet-20241022", agent: "build", }); + + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined); }); afterEach(() => { jest.clearAllMocks(); + warnSpy.mockRestore(); }); describe("Constructor", () => { @@ -33,7 +39,10 @@ describe("OpenCodeChatModel", () => { it("should accept custom configuration", () => { const customModel = new OpenCodeChatModel({ baseUrl: "http://custom:8080", + authType: "basic", apiKey: "test-key", + username: "opencode", + password: "changeme", agent: "chat", providerID: "openai", modelID: "gpt-4", @@ -42,7 +51,10 @@ describe("OpenCodeChatModel", () => { }); expect(customModel.baseUrl).toBe("http://custom:8080"); + expect(customModel.authType).toBe("basic"); expect(customModel.apiKey).toBe("test-key"); + expect(customModel.username).toBe("opencode"); + expect(customModel.password).toBe("changeme"); expect(customModel.agent).toBe("chat"); expect(customModel.providerID).toBe("openai"); expect(customModel.modelID).toBe("gpt-4"); @@ -140,9 +152,10 @@ describe("OpenCodeChatModel", () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); - it("should include API key in headers when provided", async () => { - const modelWithKey = new OpenCodeChatModel({ + it("should include bearer token in headers when provided", async () => { + const modelWithBearer = new OpenCodeChatModel({ baseUrl: "http://localhost:4096", + authType: "bearer", apiKey: "test-key-123", providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022", @@ -153,7 +166,7 @@ describe("OpenCodeChatModel", () => { json: async () => ({ id: "session-123" }), }); - await (modelWithKey as any).createSession(); + await (modelWithBearer as any).createSession(); expect(mockFetch).toHaveBeenCalledWith( expect.any(String), @@ -165,6 +178,33 @@ describe("OpenCodeChatModel", () => { ); }); + it("should include basic auth header when configured", async () => { + const modelWithBasic = new OpenCodeChatModel({ + baseUrl: "http://localhost:4096", + authType: "basic", + username: "opencode", + password: "changeme", + providerID: "anthropic", + modelID: "claude-3-5-sonnet-20241022", + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: "session-123" }), + }); + + await (modelWithBasic as any).createSession(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: basicAuthHeader, + }), + }), + ); + }); + it("should throw error on failed session creation", async () => { mockFetch.mockResolvedValueOnce({ ok: false, @@ -240,6 +280,37 @@ describe("OpenCodeChatModel", () => { ); }); + it("should include basic auth header when sending prompts", async () => { + const modelWithBasic = new OpenCodeChatModel({ + baseUrl: "http://localhost:4096", + authType: "basic", + username: "opencode", + password: "changeme", + providerID: "anthropic", + modelID: "claude-3-5-sonnet-20241022", + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + parts: [{ type: "text", text: "Response text" }], + }), + }); + + await (modelWithBasic as any).sendPrompt("session-123", [ + { type: "text", text: "Test prompt" }, + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:4096/session/session-123/message", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: basicAuthHeader, + }), + }), + ); + }); + it("should throw error on failed prompt", async () => { mockFetch.mockResolvedValueOnce({ ok: false, @@ -270,12 +341,38 @@ describe("OpenCodeChatModel", () => { ); }); + it("should include basic auth header when deleting a session", async () => { + const modelWithBasic = new OpenCodeChatModel({ + baseUrl: "http://localhost:4096", + authType: "basic", + username: "opencode", + password: "changeme", + providerID: "anthropic", + modelID: "claude-3-5-sonnet-20241022", + }); + + mockFetch.mockResolvedValueOnce({ ok: true }); + + await (modelWithBasic as any).deleteSession("session-123"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:4096/session/session-123", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: basicAuthHeader, + }), + }), + ); + }); + it("should handle cleanup errors gracefully", async () => { mockFetch.mockRejectedValueOnce(new Error("Network error")); await expect( (model as any).deleteSession("session-123"), ).resolves.not.toThrow(); + + expect(warnSpy).toHaveBeenCalled(); }); it("should do nothing on cleanup() call (deprecated)", async () => { diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..21482f5 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,45 @@ +import { getOpenCodeAuthHeaders } from "../nodes/LmChatOpenCode/auth"; + +describe("getOpenCodeAuthHeaders", () => { + it("returns no headers for missing credentials", () => { + expect(getOpenCodeAuthHeaders()).toEqual({}); + }); + + it("returns no headers for authType none", () => { + expect(getOpenCodeAuthHeaders({ authType: "none" })).toEqual({}); + }); + + it("builds a bearer auth header", () => { + expect( + getOpenCodeAuthHeaders({ + authType: "bearer", + apiKey: "test-key", + }), + ).toEqual({ + Authorization: "Bearer test-key", + }); + }); + + it("builds a basic auth header", () => { + expect( + getOpenCodeAuthHeaders({ + authType: "basic", + username: "opencode", + password: "changeme", + }), + ).toEqual({ + Authorization: `Basic ${Buffer.from("opencode:changeme", "utf8").toString("base64")}`, + }); + }); + + it("defaults the basic auth username to opencode", () => { + expect( + getOpenCodeAuthHeaders({ + authType: "basic", + password: "changeme", + }), + ).toEqual({ + Authorization: `Basic ${Buffer.from("opencode:changeme", "utf8").toString("base64")}`, + }); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index a671bf7..0265626 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -10,52 +10,124 @@ import { HumanMessage } from "@langchain/core/messages"; const OPENCODE_BASE_URL = process.env.OPENCODE_BASE_URL || "http://localhost:4096"; const RUN_INTEGRATION_TESTS = process.env.RUN_INTEGRATION_TESTS === "true"; +const OPENCODE_USERNAME = process.env.OPENCODE_USERNAME; +const OPENCODE_PASSWORD = process.env.OPENCODE_PASSWORD; + +type OpenCodeChatModelConfig = ConstructorParameters[0]; + +function getIntegrationAuthHeaders(): Record { + if (!OPENCODE_USERNAME || !OPENCODE_PASSWORD) { + return {}; + } + + return { + Authorization: `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${OPENCODE_PASSWORD}`, "utf8").toString("base64")}`, + }; +} + +function getTestModelConfig( + overrides: Partial = {}, +): OpenCodeChatModelConfig { + return { + baseUrl: OPENCODE_BASE_URL, + providerID: "anthropic", + modelID: "claude-3-5-sonnet-20241022", + agent: "build", + ...(OPENCODE_USERNAME && OPENCODE_PASSWORD + ? { + authType: "basic", + username: OPENCODE_USERNAME, + password: OPENCODE_PASSWORD, + } + : {}), + ...overrides, + }; +} + +function createTestModel(overrides: Partial = {}) { + return new OpenCodeChatModel(getTestModelConfig(overrides)); +} const describeIntegration = RUN_INTEGRATION_TESTS ? describe : describe.skip; describeIntegration("OpenCode Integration Tests", () => { - let model: OpenCodeChatModel; + let model: OpenCodeChatModel | undefined; + let serverAvailable = false; + + function skipIfServerUnavailable() { + if (!serverAvailable) { + return true; + } + + return false; + } + + function getModel(): OpenCodeChatModel { + if (!model) { + throw new Error("Integration test model was not initialized"); + } + + return model; + } beforeAll(async () => { - // Check if OpenCode server is running try { - const response = await fetch(`${OPENCODE_BASE_URL}/app`); + const response = await fetch(`${OPENCODE_BASE_URL}/global/health`, { + headers: getIntegrationAuthHeaders(), + }); if (!response.ok) { throw new Error("OpenCode server not responding"); } + + serverAvailable = true; } catch (error) { - console.warn("OpenCode server not available, skipping integration tests"); - throw error; + console.warn( + `OpenCode server not available at ${OPENCODE_BASE_URL}, skipping integration tests. Set OPENCODE_BASE_URL and optional OPENCODE_USERNAME/OPENCODE_PASSWORD to run them against a live server.`, + ); } }); beforeEach(() => { - model = new OpenCodeChatModel({ - baseUrl: OPENCODE_BASE_URL, - providerID: "anthropic", - modelID: "claude-3-5-sonnet-20241022", - agent: "build", - }); + if (!serverAvailable) { + return; + } + + model = createTestModel(); }); afterEach(async () => { + if (!model) { + return; + } + await model.cleanup(); + model = undefined; }); describe("Session Creation", () => { it("should successfully create a session", async () => { - const sessionId = await (model as any).getOrCreateSession(); + if (skipIfServerUnavailable()) { + return; + } + + const sessionId = await (getModel() as any).createSession(); expect(sessionId).toBeDefined(); expect(typeof sessionId).toBe("string"); expect(sessionId.length).toBeGreaterThan(0); + + await (model as any).deleteSession(sessionId); }, 10000); }); describe("Simple Message Generation", () => { it("should generate a response to a simple prompt", async () => { + if (skipIfServerUnavailable()) { + return; + } + const messages = [new HumanMessage('Say "Hello" and nothing else')]; - const result = await model.invoke(messages); + const result = await getModel().invoke(messages); expect(result).toBeDefined(); expect(result.content).toBeDefined(); @@ -64,13 +136,17 @@ describeIntegration("OpenCode Integration Tests", () => { }, 30000); it("should handle code generation requests", async () => { + if (skipIfServerUnavailable()) { + return; + } + const messages = [ new HumanMessage( 'Write a simple Python function that returns the string "test". Just the code, no explanation.', ), ]; - const result = await model.invoke(messages); + const result = await getModel().invoke(messages); expect(result.content).toBeDefined(); const content = result.content as string; @@ -81,10 +157,14 @@ describeIntegration("OpenCode Integration Tests", () => { describe("Streaming", () => { it("should stream responses", async () => { + if (skipIfServerUnavailable()) { + return; + } + const messages = [new HumanMessage("Count from 1 to 5")]; const chunks: string[] = []; - const stream = await model.stream(messages); + const stream = await getModel().stream(messages); for await (const chunk of stream) { if (chunk.content) { @@ -100,23 +180,28 @@ describeIntegration("OpenCode Integration Tests", () => { describe("Multiple Requests", () => { it("should handle multiple sequential requests", async () => { - const result1 = await model.invoke([new HumanMessage('Say "First"')]); + if (skipIfServerUnavailable()) { + return; + } + + const result1 = await getModel().invoke([new HumanMessage('Say "First"')]); expect(result1.content).toBeDefined(); - const result2 = await model.invoke([new HumanMessage('Say "Second"')]); + const result2 = await getModel().invoke([new HumanMessage('Say "Second"')]); expect(result2.content).toBeDefined(); - const result3 = await model.invoke([new HumanMessage('Say "Third"')]); + const result3 = await getModel().invoke([new HumanMessage('Say "Third"')]); expect(result3.content).toBeDefined(); }, 60000); }); describe("Different Agents", () => { it("should work with chat agent", async () => { - const chatModel = new OpenCodeChatModel({ - baseUrl: OPENCODE_BASE_URL, - providerID: "anthropic", - modelID: "claude-3-5-sonnet-20241022", + if (skipIfServerUnavailable()) { + return; + } + + const chatModel = createTestModel({ agent: "chat", }); @@ -129,11 +214,14 @@ describeIntegration("OpenCode Integration Tests", () => { describe("Error Handling", () => { it("should handle invalid model gracefully", async () => { + if (skipIfServerUnavailable()) { + return; + } + const invalidModel = new OpenCodeChatModel({ - baseUrl: OPENCODE_BASE_URL, + ...getTestModelConfig(), providerID: "invalid-provider" as any, modelID: "invalid-model", - agent: "build", }); await expect( @@ -150,10 +238,7 @@ export async function manualTest() { console.log("Starting manual OpenCode integration test..."); const model = new OpenCodeChatModel({ - baseUrl: OPENCODE_BASE_URL, - providerID: "anthropic", - modelID: "claude-3-5-sonnet-20241022", - agent: "build", + ...getTestModelConfig(), }); console.log("Sending test message..."); From d23ab96fe9257367632dcf13f37d475142e357fc Mon Sep 17 00:00:00 2001 From: Sergej Popov Date: Tue, 28 Apr 2026 16:25:40 +0100 Subject: [PATCH 2/2] mix: Model ID discovery built with OpenCode + GPT-5.4 --- nodes/LmChatOpenCode/LmChatOpenCode.node.ts | 27 +--- nodes/LmChatOpenCode/discovery.ts | 63 +++++++++ tests/LmChatOpenCode.node.test.ts | 136 ++++++++++++++++++++ 3 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 nodes/LmChatOpenCode/discovery.ts create mode 100644 tests/LmChatOpenCode.node.test.ts diff --git a/nodes/LmChatOpenCode/LmChatOpenCode.node.ts b/nodes/LmChatOpenCode/LmChatOpenCode.node.ts index 4e6e7d6..534aac1 100644 --- a/nodes/LmChatOpenCode/LmChatOpenCode.node.ts +++ b/nodes/LmChatOpenCode/LmChatOpenCode.node.ts @@ -9,6 +9,7 @@ import type { import { NodeConnectionTypes } from "n8n-workflow"; import { OpenCodeChatModel } from "./OpenCodeChatModel"; import { getOpenCodeAuthHeaders } from "./auth"; +import { findProviderById, mapModelOptions, mapProviderOptions } from "./discovery"; export class LmChatOpenCode implements INodeType { description: INodeTypeDescription = { @@ -72,6 +73,7 @@ export class LmChatOpenCode implements INodeType { default: "", typeOptions: { loadOptionsMethod: "getModels", + loadOptionsDependsOn: ["providerID"], }, }, { @@ -143,15 +145,7 @@ export class LmChatOpenCode implements INodeType { headers, }); - // Transform providers array to options - return response.providers - .map((provider: any) => ({ - name: provider.name, - value: provider.id, - })) - .sort((a: INodePropertyOptions, b: INodePropertyOptions) => - a.name.localeCompare(b.name), - ); + return mapProviderOptions(response.providers); } catch (error) { console.warn( "Failed to load providers from OpenCode:", @@ -222,7 +216,7 @@ export class LmChatOpenCode implements INodeType { username: credentials?.username as string | undefined, password: credentials?.password as string | undefined, }); - const providerID = this.getCurrentNodeParameter("providerID") as string; + const providerID = this.getNodeParameter("providerID") as string; if (!providerID) { return []; @@ -235,17 +229,8 @@ export class LmChatOpenCode implements INodeType { headers, }); - // Find provider in array and get models object - const provider = response.providers.find( - (p: any) => p.id === providerID, - ); - const models = provider?.models || {}; - - // Convert models object keys to array - return Object.keys(models).map((modelId: string) => ({ - name: modelId, - value: modelId, - })); + const provider = findProviderById(response.providers, providerID); + return mapModelOptions(provider); } catch (error) { console.warn( "Failed to load models from OpenCode:", diff --git a/nodes/LmChatOpenCode/discovery.ts b/nodes/LmChatOpenCode/discovery.ts new file mode 100644 index 0000000..cccc999 --- /dev/null +++ b/nodes/LmChatOpenCode/discovery.ts @@ -0,0 +1,63 @@ +import type { INodePropertyOptions } from "n8n-workflow"; + +type OpenCodeModel = { + id?: string; + name?: string; +}; + +type OpenCodeProvider = { + id?: string; + name?: string; + models?: Record | OpenCodeModel[]; +}; + +export function mapProviderOptions( + providers: OpenCodeProvider[] | undefined, +): INodePropertyOptions[] { + if (!Array.isArray(providers)) { + return []; + } + + return providers + .filter( + (provider): provider is Required> => + typeof provider?.id === "string" && typeof provider?.name === "string", + ) + .map((provider) => ({ + name: provider.name, + value: provider.id, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function mapModelOptions(provider: OpenCodeProvider | undefined): INodePropertyOptions[] { + if (!provider?.models) { + return []; + } + + const models = Array.isArray(provider.models) + ? provider.models + : Object.values(provider.models); + + return models + .filter( + (model): model is Required> & OpenCodeModel => + typeof model?.id === "string" && model.id.length > 0, + ) + .map((model) => ({ + name: model.name || model.id, + value: model.id, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function findProviderById( + providers: OpenCodeProvider[] | undefined, + providerID: string, +): OpenCodeProvider | undefined { + if (!Array.isArray(providers)) { + return undefined; + } + + return providers.find((provider) => provider.id === providerID); +} diff --git a/tests/LmChatOpenCode.node.test.ts b/tests/LmChatOpenCode.node.test.ts new file mode 100644 index 0000000..184de2f --- /dev/null +++ b/tests/LmChatOpenCode.node.test.ts @@ -0,0 +1,136 @@ +import { LmChatOpenCode } from "../nodes/LmChatOpenCode/LmChatOpenCode.node"; +import { + findProviderById, + mapModelOptions, + mapProviderOptions, +} from "../nodes/LmChatOpenCode/discovery"; + +describe("LmChatOpenCode discovery", () => { + describe("mapProviderOptions", () => { + it("maps and sorts provider options", () => { + expect( + mapProviderOptions([ + { id: "openai", name: "OpenAI" }, + { id: "anthropic", name: "Anthropic" }, + ]), + ).toEqual([ + { name: "Anthropic", value: "anthropic" }, + { name: "OpenAI", value: "openai" }, + ]); + }); + }); + + describe("findProviderById", () => { + it("finds a provider by id", () => { + const provider = findProviderById( + [ + { id: "openai", name: "OpenAI" }, + { id: "opencode", name: "OpenCode Zen" }, + ], + "opencode", + ); + + expect(provider).toEqual({ id: "opencode", name: "OpenCode Zen" }); + }); + }); + + describe("mapModelOptions", () => { + it("maps model options from object-shaped model config", () => { + expect( + mapModelOptions({ + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4-mini": { id: "gpt-5.4-mini", name: "GPT-5.4 mini" }, + "gpt-5.4": { id: "gpt-5.4", name: "GPT-5.4" }, + }, + }), + ).toEqual([ + { name: "GPT-5.4", value: "gpt-5.4" }, + { name: "GPT-5.4 mini", value: "gpt-5.4-mini" }, + ]); + }); + + it("maps model options from array-shaped model config", () => { + expect( + mapModelOptions({ + id: "openai", + name: "OpenAI", + models: [ + { id: "gpt-5.4-mini", name: "GPT-5.4 mini" }, + { id: "gpt-5.4" }, + ], + }), + ).toEqual([ + { name: "gpt-5.4", value: "gpt-5.4" }, + { name: "GPT-5.4 mini", value: "gpt-5.4-mini" }, + ]); + }); + }); + + describe("getModels load option", () => { + it("fetches models for the selected provider", async () => { + const node = new LmChatOpenCode(); + const httpRequest = jest.fn().mockResolvedValue({ + providers: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4-mini": { id: "gpt-5.4-mini", name: "GPT-5.4 mini" }, + "gpt-5.4": { id: "gpt-5.4", name: "GPT-5.4" }, + }, + }, + { + id: "opencode", + name: "OpenCode Zen", + models: { + "big-pickle": { id: "big-pickle", name: "Big Pickle" }, + }, + }, + ], + }); + + const result = await node.methods.loadOptions.getModels.call({ + getCredentials: jest.fn().mockResolvedValue({ + baseUrl: "https://opencode-server.sergej.uk", + authType: "basic", + username: "opencode", + password: "changeme", + }), + getNodeParameter: jest.fn().mockReturnValue("opencode"), + helpers: { + httpRequest, + }, + } as any); + + expect(httpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: "https://opencode-server.sergej.uk/config/providers", + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from("opencode:changeme", "utf8").toString("base64")}`, + }), + }), + ); + + expect(result).toEqual([{ name: "Big Pickle", value: "big-pickle" }]); + }); + + it("returns empty array when no provider is selected", async () => { + const node = new LmChatOpenCode(); + const httpRequest = jest.fn(); + + const result = await node.methods.loadOptions.getModels.call({ + getCredentials: jest.fn().mockResolvedValue({}), + getNodeParameter: jest.fn().mockReturnValue(""), + helpers: { + httpRequest, + }, + } as any); + + expect(result).toEqual([]); + expect(httpRequest).not.toHaveBeenCalled(); + }); + }); +});