Skip to content
Closed
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
13 changes: 13 additions & 0 deletions src/app/lib/ai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { registerAIProvider, resolveAIProvider } from "./registry";
import { createOpenAICompatibleProvider } from "./providers/openai-compatible";

registerAIProvider("openai_compatible", createOpenAICompatibleProvider);

export { resolveAIProvider, registerAIProvider };
export type {
AIProviderAdapter,
AIProviderChatRequest,
AIProviderConfig,
AIProviderType,
OpenAICompatibleProviderConfig,
} from "./types";
38 changes: 38 additions & 0 deletions src/app/lib/ai/providers/openai-compatible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {
AIProviderAdapter,
AIProviderChatRequest,
OpenAICompatibleProviderConfig,
} from "../types";

function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}

export function createOpenAICompatibleProvider(
config: OpenAICompatibleProviderConfig,
): AIProviderAdapter {
const baseUrl = normalizeBaseUrl(config.baseUrl);

return {
type: "openai_compatible",
model: config.model,
async chatCompletions(
request: AIProviderChatRequest,
init?: Omit<RequestInit, "method" | "body" | "headers">,
): Promise<Response> {
return fetch(`${baseUrl}/chat/completions`, {
...init,
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
...config.headers,
},
body: JSON.stringify({
model: config.model,
...request,
}),
});
},
};
}
60 changes: 60 additions & 0 deletions src/app/lib/ai/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
registerAIProvider,
resolveAIProvider,
getRegisteredAIProviders,
} from "./registry";
import { createOpenAICompatibleProvider } from "./providers/openai-compatible";

describe("AI provider registry", () => {
beforeEach(() => {
registerAIProvider("openai_compatible", createOpenAICompatibleProvider);
});

it("resolves openai_compatible provider and forwards configured request fields", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("{}"));
vi.stubGlobal("fetch", fetchMock);

const provider = resolveAIProvider({
type: "openai_compatible",
baseUrl: "https://api.example.com/v1/",
apiKey: "secret",
model: "gpt-4.1-mini",
headers: {
"X-Custom": "yes",
},
});

await provider.chatCompletions({
messages: [{ role: "user", content: "hello" }],
temperature: 0.2,
});

expect(provider.type).toBe("openai_compatible");
expect(provider.model).toBe("gpt-4.1-mini");
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer secret",
"X-Custom": "yes",
}),
}),
);

const fetchCall = fetchMock.mock.calls[0];
const body = JSON.parse(fetchCall?.[1]?.body as string);
expect(body).toMatchObject({
model: "gpt-4.1-mini",
messages: [{ role: "user", content: "hello" }],
temperature: 0.2,
});
});

it("exposes registered provider types", () => {
expect(getRegisteredAIProviders()).toContain("openai_compatible");
});
});
32 changes: 32 additions & 0 deletions src/app/lib/ai/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type {
AIProviderAdapter,
AIProviderConfig,
AIProviderType,
} from "./types";

export type AIProviderFactory<T extends AIProviderConfig = AIProviderConfig> = (
config: T,
) => AIProviderAdapter;

const providerRegistry = new Map<AIProviderType, AIProviderFactory>();

export function registerAIProvider(
type: AIProviderType,
factory: AIProviderFactory,
): void {
providerRegistry.set(type, factory);
}

export function resolveAIProvider(config: AIProviderConfig): AIProviderAdapter {
const factory = providerRegistry.get(config.type);

if (!factory) {
throw new Error(`No AI provider registered for type: ${config.type}`);
}

return factory(config);
}

export function getRegisteredAIProviders(): AIProviderType[] {
return Array.from(providerRegistry.keys());
}
36 changes: 36 additions & 0 deletions src/app/lib/ai/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type AIProviderType = "openai_compatible";

export interface AIProviderConfigBase {
type: AIProviderType;
}

export interface OpenAICompatibleProviderConfig extends AIProviderConfigBase {
type: "openai_compatible";
baseUrl: string;
apiKey: string;
model: string;
headers?: Record<string, string>;
}

export type AIProviderConfig = OpenAICompatibleProviderConfig;

export interface AIChatMessage {
role: "system" | "user" | "assistant";
content: string;
}

export interface AIProviderChatRequest {
messages: AIChatMessage[];
temperature?: number;
max_tokens?: number;
[key: string]: unknown;
}

export interface AIProviderAdapter {
readonly type: AIProviderType;
readonly model: string;
chatCompletions(
request: AIProviderChatRequest,
init?: Omit<RequestInit, "method" | "body" | "headers">,
): Promise<Response>;
}
Loading