Skip to content
Open
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
104 changes: 84 additions & 20 deletions credentials/OpenCodeApi.credentials.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
ICredentialTestRequest,
ICredentialType,
IHttpRequestOptions,
INodeProperties,
} from "n8n-workflow";

Expand All @@ -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<IHttpRequestOptions> => {
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",
},
},
};
Expand Down
7 changes: 7 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/tests/**/*.test.ts"],
modulePathIgnorePatterns: ["<rootDir>/dist/"],
clearMocks: true,
};
58 changes: 31 additions & 27 deletions nodes/LmChatOpenCode/LmChatOpenCode.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
} from "n8n-workflow";
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 = {
Expand Down Expand Up @@ -71,6 +73,7 @@ export class LmChatOpenCode implements INodeType {
default: "",
typeOptions: {
loadOptionsMethod: "getModels",
loadOptionsDependsOn: ["providerID"],
},
},
{
Expand Down Expand Up @@ -128,24 +131,21 @@ 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
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:",
Expand All @@ -166,13 +166,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
Expand Down Expand Up @@ -205,8 +210,13 @@ 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 providerID = this.getCurrentNodeParameter("providerID") as string;
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.getNodeParameter("providerID") as string;

if (!providerID) {
return [];
Expand All @@ -216,20 +226,11 @@ 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
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:",
Expand Down Expand Up @@ -264,7 +265,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,
Expand Down
40 changes: 28 additions & 12 deletions nodes/LmChatOpenCode/OpenCodeChatModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -142,12 +152,14 @@ export class OpenCodeChatModel extends BaseChatModel {
private async createSession(): Promise<string> {
const headers: Record<string, string> = {
"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);

Expand Down Expand Up @@ -234,12 +246,14 @@ export class OpenCodeChatModel extends BaseChatModel {
): Promise<string> {
const headers: Record<string, string> = {
"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);

Expand Down Expand Up @@ -318,10 +332,12 @@ export class OpenCodeChatModel extends BaseChatModel {

private async deleteSession(sessionId: string): Promise<void> {
try {
const headers: Record<string, string> = {};
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(
Expand Down
Loading