Add support for generic OpenAI-compatible endpoint LLM provider (#253)
* initial * fix regressions * Fix OpenAI-compatible provider aliases * Normalize OpenAI-compatible settings aliases * Update shared/src/settings-registry.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
6454efd9a2
commit
faea61a249
@ -69,7 +69,7 @@ docker compose up -d
|
|||||||
## Why JobOps?
|
## Why JobOps?
|
||||||
|
|
||||||
* **Universal Scraping**: Supports **LinkedIn, Indeed, Glassdoor, Adzuna, Hiring Café, Gradcracker, UK Visa Jobs**.
|
* **Universal Scraping**: Supports **LinkedIn, Indeed, Glassdoor, Adzuna, Hiring Café, Gradcracker, UK Visa Jobs**.
|
||||||
* **AI Scoring**: Ranks jobs by fit against *your* profile using your preferred LLM (OpenRouter/OpenAI/Gemini).
|
* **AI Scoring**: Ranks jobs by fit against *your* profile using your preferred LLM (OpenAI, OpenRouter, `openai-compatible` endpoints such as LM Studio/Ollama, Gemini).
|
||||||
* **Auto-Tailoring**: Generates custom resumes (PDFs) for every application using RxResume v4.
|
* **Auto-Tailoring**: Generates custom resumes (PDFs) for every application using RxResume v4.
|
||||||
* **Email Tracking**: Connect Gmail to auto-detect interviews, offers, and rejections.
|
* **Email Tracking**: Connect Gmail to auto-detect interviews, offers, and rejections.
|
||||||
* **Self-Hosted**: Your data stays with you. SQLite database. No SaaS fees.
|
* **Self-Hosted**: Your data stays with you. SQLite database. No SaaS fees.
|
||||||
|
|||||||
@ -40,9 +40,11 @@ orchestrator/
|
|||||||
|
|
||||||
`v5` (API key) is recommended for self-hosted/latest Reactive Resume. Use `v4` when connecting to the legacy email/password flow.
|
`v5` (API key) is recommended for self-hosted/latest Reactive Resume. Use `v4` when connecting to the legacy email/password flow.
|
||||||
|
|
||||||
OpenRouter is the default LLM provider, but LM Studio, Ollama, OpenAI, and Gemini are also supported.
|
OpenRouter is the default LLM provider, but OpenAI, LM Studio, Ollama, `openai-compatible` endpoints, and Gemini are also supported.
|
||||||
|
|
||||||
Use `LLM_API_KEY` / `llmApiKey` to configure providers that require an API key.
|
Use `LLM_API_KEY` / `llmApiKey` to configure providers that require an API key.
|
||||||
|
To use the native OpenAI integration, set `LLM_PROVIDER=openai`.
|
||||||
|
For third-party services that expose an OpenAI-style API but are not OpenAI itself, use `LLM_PROVIDER=openai-compatible`.
|
||||||
|
|
||||||
3. **Initialize database:**
|
3. **Initialize database:**
|
||||||
```bash
|
```bash
|
||||||
@ -143,6 +145,6 @@ npm start
|
|||||||
|
|
||||||
- **Backend:** Express, TypeScript, Drizzle ORM, SQLite
|
- **Backend:** Express, TypeScript, Drizzle ORM, SQLite
|
||||||
- **Frontend:** React, Vite, CSS (custom design system)
|
- **Frontend:** React, Vite, CSS (custom design system)
|
||||||
- **AI:** Configurable LLM provider (OpenRouter default; also supports OpenAI/Gemini/LM Studio/Ollama)
|
- **AI:** Configurable LLM provider (OpenRouter default; also supports OpenAI via the dedicated `openai` provider, `openai-compatible` endpoints, Gemini, LM Studio, and Ollama)
|
||||||
- **PDF Generation:** Reactive Resume v4/v5 API export (configured via Settings)
|
- **PDF Generation:** Reactive Resume v4/v5 API export (configured via Settings)
|
||||||
- **Job Crawling:** Wraps existing TypeScript Crawlee crawler
|
- **Job Crawling:** Wraps existing TypeScript Crawlee crawler
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import type { ModelValues } from "@client/pages/settings/types";
|
|||||||
import {
|
import {
|
||||||
formatSecretHint,
|
formatSecretHint,
|
||||||
getLlmProviderConfig,
|
getLlmProviderConfig,
|
||||||
|
LLM_PROVIDER_LABELS,
|
||||||
|
LLM_PROVIDERS,
|
||||||
} from "@client/pages/settings/utils";
|
} from "@client/pages/settings/utils";
|
||||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
@ -97,11 +99,11 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
<SelectValue placeholder="Select provider" />
|
<SelectValue placeholder="Select provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
{LLM_PROVIDERS.map((provider) => (
|
||||||
<SelectItem value="lmstudio">LM Studio</SelectItem>
|
<SelectItem key={provider} value={provider}>
|
||||||
<SelectItem value="ollama">Ollama</SelectItem>
|
{LLM_PROVIDER_LABELS[provider]}
|
||||||
<SelectItem value="openai">OpenAI</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="gemini">Gemini</SelectItem>
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|||||||
23
orchestrator/src/client/pages/settings/utils.test.ts
Normal file
23
orchestrator/src/client/pages/settings/utils.test.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getLlmProviderConfig, normalizeLlmProvider } from "./utils";
|
||||||
|
|
||||||
|
describe("settings utils", () => {
|
||||||
|
it("treats openai-compatible as a dedicated configurable provider", () => {
|
||||||
|
const config = getLlmProviderConfig("openai_compatible");
|
||||||
|
|
||||||
|
expect(config.label).toBe("OpenAI-compatible");
|
||||||
|
expect(config.showApiKey).toBe(true);
|
||||||
|
expect(config.showBaseUrl).toBe(true);
|
||||||
|
expect(config.baseUrlPlaceholder).toBe(
|
||||||
|
"https://api.example.com/v1/chat/completions",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes the hyphenated openai-compatible alias", () => {
|
||||||
|
expect(normalizeLlmProvider("openai-compatible")).toBe("openai_compatible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults unknown providers to openrouter", () => {
|
||||||
|
expect(normalizeLlmProvider("unknown-provider")).toBe("openrouter");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -24,6 +24,7 @@ export const LLM_PROVIDERS = [
|
|||||||
"lmstudio",
|
"lmstudio",
|
||||||
"ollama",
|
"ollama",
|
||||||
"openai",
|
"openai",
|
||||||
|
"openai_compatible",
|
||||||
"gemini",
|
"gemini",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -34,16 +35,22 @@ export const LLM_PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
|||||||
lmstudio: "LM Studio",
|
lmstudio: "LM Studio",
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
openai: "OpenAI",
|
openai: "OpenAI",
|
||||||
|
openai_compatible: "OpenAI-compatible",
|
||||||
gemini: "Gemini",
|
gemini: "Gemini",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDERS_WITH_API_KEY = new Set<LlmProviderId>([
|
const PROVIDERS_WITH_API_KEY = new Set<LlmProviderId>([
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"openai",
|
"openai",
|
||||||
|
"openai_compatible",
|
||||||
"gemini",
|
"gemini",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PROVIDERS_WITH_BASE_URL = new Set<LlmProviderId>(["lmstudio", "ollama"]);
|
const PROVIDERS_WITH_BASE_URL = new Set<LlmProviderId>([
|
||||||
|
"lmstudio",
|
||||||
|
"ollama",
|
||||||
|
"openai_compatible",
|
||||||
|
]);
|
||||||
|
|
||||||
const PROVIDER_HINTS: Record<LlmProviderId, string> = {
|
const PROVIDER_HINTS: Record<LlmProviderId, string> = {
|
||||||
openrouter:
|
openrouter:
|
||||||
@ -51,6 +58,8 @@ const PROVIDER_HINTS: Record<LlmProviderId, string> = {
|
|||||||
lmstudio: "LM Studio runs locally via its OpenAI-compatible server.",
|
lmstudio: "LM Studio runs locally via its OpenAI-compatible server.",
|
||||||
ollama: "Ollama typically runs locally and does not require an API key.",
|
ollama: "Ollama typically runs locally and does not require an API key.",
|
||||||
openai: "OpenAI uses the Responses API with structured outputs.",
|
openai: "OpenAI uses the Responses API with structured outputs.",
|
||||||
|
openai_compatible:
|
||||||
|
"Use a bearer token with any chat-completions-compatible endpoint.",
|
||||||
gemini: "Gemini uses the native AI Studio API and requires a key.",
|
gemini: "Gemini uses the native AI Studio API and requires a key.",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -59,15 +68,17 @@ const PROVIDER_KEY_HELPERS: Record<LlmProviderId, string> = {
|
|||||||
lmstudio: "No API key required for LM Studio",
|
lmstudio: "No API key required for LM Studio",
|
||||||
ollama: "No API key required for Ollama",
|
ollama: "No API key required for Ollama",
|
||||||
openai: "Create a key at platform.openai.com",
|
openai: "Create a key at platform.openai.com",
|
||||||
|
openai_compatible: "Use the bearer token issued by your compatible provider",
|
||||||
gemini: "Create a key at aistudio.google.com/api-keys",
|
gemini: "Create a key at aistudio.google.com/api-keys",
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_URL_PROVIDERS = ["lmstudio", "ollama"] as const;
|
const BASE_URL_PROVIDERS = ["lmstudio", "ollama", "openai_compatible"] as const;
|
||||||
type BaseUrlProviderId = (typeof BASE_URL_PROVIDERS)[number];
|
type BaseUrlProviderId = (typeof BASE_URL_PROVIDERS)[number];
|
||||||
|
|
||||||
const PROVIDER_BASE_URLS: Record<BaseUrlProviderId, string> = {
|
const PROVIDER_BASE_URLS: Record<BaseUrlProviderId, string> = {
|
||||||
lmstudio: "http://localhost:1234",
|
lmstudio: "http://localhost:1234",
|
||||||
ollama: "http://localhost:11434",
|
ollama: "http://localhost:11434",
|
||||||
|
openai_compatible: "https://api.example.com/v1/chat/completions",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeLlmProvider(
|
export function normalizeLlmProvider(
|
||||||
@ -75,6 +86,7 @@ export function normalizeLlmProvider(
|
|||||||
): LlmProviderId {
|
): LlmProviderId {
|
||||||
const normalized = value?.trim().toLowerCase();
|
const normalized = value?.trim().toLowerCase();
|
||||||
if (!normalized) return "openrouter";
|
if (!normalized) return "openrouter";
|
||||||
|
if (normalized === "openai-compatible") return "openai_compatible";
|
||||||
return (LLM_PROVIDERS as readonly string[]).includes(normalized)
|
return (LLM_PROVIDERS as readonly string[]).includes(normalized)
|
||||||
? (normalized as LlmProviderId)
|
? (normalized as LlmProviderId)
|
||||||
: "openrouter";
|
: "openrouter";
|
||||||
@ -87,7 +99,11 @@ export function getLlmProviderConfig(provider: string | null | undefined) {
|
|||||||
const baseUrlPlaceholder = showBaseUrl
|
const baseUrlPlaceholder = showBaseUrl
|
||||||
? PROVIDER_BASE_URLS[normalizedProvider as BaseUrlProviderId]
|
? PROVIDER_BASE_URLS[normalizedProvider as BaseUrlProviderId]
|
||||||
: "";
|
: "";
|
||||||
const baseUrlHelper = showBaseUrl ? `Default: ${baseUrlPlaceholder}` : "";
|
const baseUrlHelper = showBaseUrl
|
||||||
|
? normalizedProvider === "openai_compatible"
|
||||||
|
? "Enter a base URL or a full /v1/chat/completions endpoint."
|
||||||
|
: `Default: ${baseUrlPlaceholder}`
|
||||||
|
: "";
|
||||||
const providerHint = PROVIDER_HINTS[normalizedProvider];
|
const providerHint = PROVIDER_HINTS[normalizedProvider];
|
||||||
const keyHelper = PROVIDER_KEY_HELPERS[normalizedProvider];
|
const keyHelper = PROVIDER_KEY_HELPERS[normalizedProvider];
|
||||||
|
|
||||||
|
|||||||
@ -210,6 +210,59 @@ describe.sequential("Onboarding API routes", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the provided baseUrl for the hyphenated OpenAI-compatible alias", async () => {
|
||||||
|
global.fetch = vi.fn((input, init) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
if (url.startsWith("https://llm.example.com/v1/models")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: [] }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.startsWith("https://api.openai.com/v1/models")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ error: { message: "wrong endpoint used" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: "openai-compatible",
|
||||||
|
apiKey: "test-compatible-key",
|
||||||
|
baseUrl: "https://llm.example.com/v1/",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.valid).toBe(true);
|
||||||
|
expect(body.data.message).toBeNull();
|
||||||
|
const fetchCalls = vi.mocked(global.fetch).mock.calls.map((call) => {
|
||||||
|
const requestInput = call[0];
|
||||||
|
if (typeof requestInput === "string") return requestInput;
|
||||||
|
if (requestInput instanceof URL) return requestInput.href;
|
||||||
|
return requestInput.url;
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
fetchCalls.some((url) =>
|
||||||
|
url.startsWith("https://llm.example.com/v1/models"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
fetchCalls.some((url) =>
|
||||||
|
url.startsWith("https://api.openai.com/v1/models"),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/onboarding/validate/rxresume", () => {
|
describe("POST /api/onboarding/validate/rxresume", () => {
|
||||||
|
|||||||
@ -30,10 +30,13 @@ async function validateLlm(options: {
|
|||||||
getSetting("llmBaseUrl"),
|
getSetting("llmBaseUrl"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const normalizedProvider =
|
const normalizedProvider = normalizeLlmProviderValue(
|
||||||
options.provider?.trim() || storedProvider?.trim() || undefined;
|
options.provider?.trim() || storedProvider?.trim() || undefined,
|
||||||
|
);
|
||||||
const shouldUseBaseUrl =
|
const shouldUseBaseUrl =
|
||||||
normalizedProvider === "lmstudio" || normalizedProvider === "ollama";
|
normalizedProvider === "lmstudio" ||
|
||||||
|
normalizedProvider === "ollama" ||
|
||||||
|
normalizedProvider === "openai_compatible";
|
||||||
const resolvedBaseUrl = shouldUseBaseUrl
|
const resolvedBaseUrl = shouldUseBaseUrl
|
||||||
? options.baseUrl?.trim() || storedBaseUrl?.trim() || undefined
|
? options.baseUrl?.trim() || storedBaseUrl?.trim() || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -54,6 +57,13 @@ async function validateLlm(options: {
|
|||||||
return llm.validateCredentials();
|
return llm.validateCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLlmProviderValue(
|
||||||
|
provider: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!provider) return undefined;
|
||||||
|
return provider.toLowerCase().replace(/-/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a base resume is configured and accessible via Reactive Resume.
|
* Validate that a base resume is configured and accessible via Reactive Resume.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -88,6 +88,28 @@ describe.sequential("Settings API routes", () => {
|
|||||||
expect(body.data.basicAuthActive).toBe(false);
|
expect(body.data.basicAuthActive).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes hyphenated openai-compatible env defaults", async () => {
|
||||||
|
const hyphenated = await startServer({
|
||||||
|
env: {
|
||||||
|
LLM_API_KEY: "secret-key",
|
||||||
|
LLM_PROVIDER: "openai-compatible",
|
||||||
|
RXRESUME_EMAIL: "resume@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${hyphenated.baseUrl}/api/settings`);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.llmProvider.default).toBe("openai_compatible");
|
||||||
|
expect(body.data.llmProvider.value).toBe("openai_compatible");
|
||||||
|
expect(body.data.llmBaseUrl.default).toBe("https://api.openai.com");
|
||||||
|
} finally {
|
||||||
|
await stopServer(hyphenated);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects invalid settings updates and persists overrides", async () => {
|
it("rejects invalid settings updates and persists overrides", async () => {
|
||||||
const badPatch = await fetch(`${baseUrl}/api/settings`, {
|
const badPatch = await fetch(`${baseUrl}/api/settings`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { geminiStrategy } from "./gemini";
|
|||||||
import { lmStudioStrategy } from "./lmstudio";
|
import { lmStudioStrategy } from "./lmstudio";
|
||||||
import { ollamaStrategy } from "./ollama";
|
import { ollamaStrategy } from "./ollama";
|
||||||
import { openAiStrategy } from "./openai";
|
import { openAiStrategy } from "./openai";
|
||||||
|
import { openAiCompatibleStrategy } from "./openai-compatible";
|
||||||
import { openRouterStrategy } from "./openrouter";
|
import { openRouterStrategy } from "./openrouter";
|
||||||
|
|
||||||
export const strategies: Record<LlmProvider, ProviderStrategy> = {
|
export const strategies: Record<LlmProvider, ProviderStrategy> = {
|
||||||
@ -10,5 +11,6 @@ export const strategies: Record<LlmProvider, ProviderStrategy> = {
|
|||||||
lmstudio: lmStudioStrategy,
|
lmstudio: lmStudioStrategy,
|
||||||
ollama: ollamaStrategy,
|
ollama: ollamaStrategy,
|
||||||
openai: openAiStrategy,
|
openai: openAiStrategy,
|
||||||
|
openai_compatible: openAiCompatibleStrategy,
|
||||||
gemini: geminiStrategy,
|
gemini: geminiStrategy,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { buildHeaders, joinUrl } from "../utils/http";
|
||||||
|
import {
|
||||||
|
buildChatCompletionsBody,
|
||||||
|
createProviderStrategy,
|
||||||
|
extractChatCompletionsText,
|
||||||
|
} from "./factory";
|
||||||
|
|
||||||
|
const CHAT_COMPLETIONS_SUFFIX = "/v1/chat/completions";
|
||||||
|
const MODELS_SUFFIX = "/v1/models";
|
||||||
|
const API_VERSION_SUFFIX = "/v1";
|
||||||
|
|
||||||
|
function normalizeBaseUrlOrEndpoint(baseUrlOrEndpoint: string): string {
|
||||||
|
return baseUrlOrEndpoint.trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendVersionedPath(baseUrl: string, path: string): string {
|
||||||
|
if (baseUrl.endsWith(API_VERSION_SUFFIX)) {
|
||||||
|
return joinUrl(baseUrl.slice(0, -API_VERSION_SUFFIX.length), path);
|
||||||
|
}
|
||||||
|
return joinUrl(baseUrl, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChatCompletionsUrl(baseUrlOrEndpoint: string): string {
|
||||||
|
const normalized = normalizeBaseUrlOrEndpoint(baseUrlOrEndpoint);
|
||||||
|
if (
|
||||||
|
normalized.endsWith(CHAT_COMPLETIONS_SUFFIX) ||
|
||||||
|
normalized.endsWith("/chat/completions")
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return appendVersionedPath(normalized, CHAT_COMPLETIONS_SUFFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelsUrl(baseUrlOrEndpoint: string): string {
|
||||||
|
const normalized = normalizeBaseUrlOrEndpoint(baseUrlOrEndpoint);
|
||||||
|
if (normalized.endsWith(CHAT_COMPLETIONS_SUFFIX)) {
|
||||||
|
return `${normalized.slice(0, -"/chat/completions".length)}/models`;
|
||||||
|
}
|
||||||
|
if (normalized.endsWith("/chat/completions")) {
|
||||||
|
return normalized.replace(/\/chat\/completions$/, "/models");
|
||||||
|
}
|
||||||
|
return appendVersionedPath(normalized, MODELS_SUFFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openAiCompatibleStrategy = createProviderStrategy({
|
||||||
|
provider: "openai_compatible",
|
||||||
|
defaultBaseUrl: "https://api.openai.com",
|
||||||
|
requiresApiKey: true,
|
||||||
|
modes: ["json_schema", "json_object", "text", "none"],
|
||||||
|
validationPaths: ["/v1/models"],
|
||||||
|
getValidationUrls: ({ baseUrl }) => [resolveModelsUrl(baseUrl)],
|
||||||
|
buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => {
|
||||||
|
return {
|
||||||
|
url: resolveChatCompletionsUrl(baseUrl),
|
||||||
|
headers: buildHeaders({ apiKey, provider: "openai_compatible" }),
|
||||||
|
body: buildChatCompletionsBody({ mode, model, messages, jsonSchema }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractText: extractChatCompletionsText,
|
||||||
|
});
|
||||||
@ -3,6 +3,7 @@ import { geminiStrategy } from "./gemini";
|
|||||||
import { lmStudioStrategy } from "./lmstudio";
|
import { lmStudioStrategy } from "./lmstudio";
|
||||||
import { ollamaStrategy } from "./ollama";
|
import { ollamaStrategy } from "./ollama";
|
||||||
import { openAiStrategy } from "./openai";
|
import { openAiStrategy } from "./openai";
|
||||||
|
import { openAiCompatibleStrategy } from "./openai-compatible";
|
||||||
import { openRouterStrategy } from "./openrouter";
|
import { openRouterStrategy } from "./openrouter";
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
@ -43,6 +44,42 @@ describe("provider adapters", () => {
|
|||||||
},
|
},
|
||||||
expectedUrl: "https://api.openai.com/v1/responses",
|
expectedUrl: "https://api.openai.com/v1/responses",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "openai-compatible-json_object",
|
||||||
|
strategy: openAiCompatibleStrategy,
|
||||||
|
args: {
|
||||||
|
mode: "json_object" as const,
|
||||||
|
baseUrl: "https://llm.example.com/v1/chat/completions",
|
||||||
|
apiKey: "x",
|
||||||
|
model: "model-a",
|
||||||
|
},
|
||||||
|
expectedUrl: "https://llm.example.com/v1/chat/completions",
|
||||||
|
expectedResponseFormat: "json_object",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai-compatible-json_object-base-root",
|
||||||
|
strategy: openAiCompatibleStrategy,
|
||||||
|
args: {
|
||||||
|
mode: "json_object" as const,
|
||||||
|
baseUrl: "https://llm.example.com",
|
||||||
|
apiKey: "x",
|
||||||
|
model: "model-a",
|
||||||
|
},
|
||||||
|
expectedUrl: "https://llm.example.com/v1/chat/completions",
|
||||||
|
expectedResponseFormat: "json_object",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai-compatible-json_object-base-v1",
|
||||||
|
strategy: openAiCompatibleStrategy,
|
||||||
|
args: {
|
||||||
|
mode: "json_object" as const,
|
||||||
|
baseUrl: "https://llm.example.com/v1/",
|
||||||
|
apiKey: "x",
|
||||||
|
model: "model-a",
|
||||||
|
},
|
||||||
|
expectedUrl: "https://llm.example.com/v1/chat/completions",
|
||||||
|
expectedResponseFormat: "json_object",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "gemini-json_schema",
|
name: "gemini-json_schema",
|
||||||
strategy: geminiStrategy,
|
strategy: geminiStrategy,
|
||||||
@ -114,6 +151,27 @@ describe("provider adapters", () => {
|
|||||||
expect(ollamaStrategy.extractText(response)).toBe("ok");
|
expect(ollamaStrategy.extractText(response)).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds validation URLs for OpenAI-compatible base URLs and endpoints", () => {
|
||||||
|
expect(
|
||||||
|
openAiCompatibleStrategy.getValidationUrls({
|
||||||
|
baseUrl: "https://llm.example.com",
|
||||||
|
apiKey: "x",
|
||||||
|
}),
|
||||||
|
).toEqual(["https://llm.example.com/v1/models"]);
|
||||||
|
expect(
|
||||||
|
openAiCompatibleStrategy.getValidationUrls({
|
||||||
|
baseUrl: "https://llm.example.com/v1/",
|
||||||
|
apiKey: "x",
|
||||||
|
}),
|
||||||
|
).toEqual(["https://llm.example.com/v1/models"]);
|
||||||
|
expect(
|
||||||
|
openAiCompatibleStrategy.getValidationUrls({
|
||||||
|
baseUrl: "https://llm.example.com/v1/chat/completions",
|
||||||
|
apiKey: "x",
|
||||||
|
}),
|
||||||
|
).toEqual(["https://llm.example.com/v1/models"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("extracts text for openai and gemini variants", () => {
|
it("extracts text for openai and gemini variants", () => {
|
||||||
expect(openAiStrategy.extractText({ output_text: "openai-direct" })).toBe(
|
expect(openAiStrategy.extractText({ output_text: "openai-direct" })).toBe(
|
||||||
"openai-direct",
|
"openai-direct",
|
||||||
|
|||||||
34
orchestrator/src/server/services/llm/service.test.ts
Normal file
34
orchestrator/src/server/services/llm/service.test.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { LlmService } from "./service";
|
||||||
|
|
||||||
|
describe("LlmService provider normalization", () => {
|
||||||
|
it("keeps legacy localhost openai_compatible configs on LM Studio", () => {
|
||||||
|
const llm = new LlmService({
|
||||||
|
provider: "openai_compatible",
|
||||||
|
baseUrl: "http://localhost:1234",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(llm.getProvider()).toBe("lmstudio");
|
||||||
|
expect(llm.getBaseUrl()).toBe("http://localhost:1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dedicated provider for non-local OpenAI-compatible endpoints", () => {
|
||||||
|
const llm = new LlmService({
|
||||||
|
provider: "openai_compatible",
|
||||||
|
baseUrl: "https://llm.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(llm.getProvider()).toBe("openai_compatible");
|
||||||
|
expect(llm.getBaseUrl()).toBe("https://llm.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes the hyphenated openai-compatible alias", () => {
|
||||||
|
const llm = new LlmService({
|
||||||
|
provider: "openai-compatible",
|
||||||
|
baseUrl: "https://llm.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(llm.getProvider()).toBe("openai_compatible");
|
||||||
|
expect(llm.getBaseUrl()).toBe("https://llm.example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -285,7 +285,7 @@ function normalizeProvider(
|
|||||||
raw: string | null,
|
raw: string | null,
|
||||||
baseUrl: string | null,
|
baseUrl: string | null,
|
||||||
): LlmProvider {
|
): LlmProvider {
|
||||||
const normalized = raw?.trim().toLowerCase();
|
const normalized = raw?.trim().toLowerCase().replace(/-/g, "_");
|
||||||
if (normalized === "openai_compatible") {
|
if (normalized === "openai_compatible") {
|
||||||
if (
|
if (
|
||||||
baseUrl?.includes("localhost:1234") ||
|
baseUrl?.includes("localhost:1234") ||
|
||||||
@ -293,7 +293,7 @@ function normalizeProvider(
|
|||||||
) {
|
) {
|
||||||
return "lmstudio";
|
return "lmstudio";
|
||||||
}
|
}
|
||||||
return "openai";
|
return "openai_compatible";
|
||||||
}
|
}
|
||||||
if (normalized === "openai") return "openai";
|
if (normalized === "openai") return "openai";
|
||||||
if (normalized === "gemini") return "gemini";
|
if (normalized === "gemini") return "gemini";
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export type LlmProvider =
|
|||||||
| "lmstudio"
|
| "lmstudio"
|
||||||
| "ollama"
|
| "ollama"
|
||||||
| "openai"
|
| "openai"
|
||||||
|
| "openai_compatible"
|
||||||
| "gemini";
|
| "gemini";
|
||||||
|
|
||||||
export type ResponseMode = "json_schema" | "json_object" | "text" | "none";
|
export type ResponseMode = "json_schema" | "json_object" | "text" | "none";
|
||||||
|
|||||||
@ -13,12 +13,15 @@ import {
|
|||||||
import { resolveRxResumeBaseResumeIdForMode } from "./rxresume/baseResumeId";
|
import { resolveRxResumeBaseResumeIdForMode } from "./rxresume/baseResumeId";
|
||||||
|
|
||||||
function resolveDefaultLlmBaseUrl(provider: string): string {
|
function resolveDefaultLlmBaseUrl(provider: string): string {
|
||||||
const normalized = provider.trim().toLowerCase();
|
const normalized = provider.trim().toLowerCase().replace(/-/g, "_");
|
||||||
if (normalized === "ollama") return "http://localhost:11434";
|
if (normalized === "ollama") return "http://localhost:11434";
|
||||||
if (normalized === "lmstudio") return "http://localhost:1234";
|
if (normalized === "lmstudio") return "http://localhost:1234";
|
||||||
if (normalized === "openai") {
|
if (normalized === "openai") {
|
||||||
return "https://api.openai.com";
|
return "https://api.openai.com";
|
||||||
}
|
}
|
||||||
|
if (normalized === "openai_compatible") {
|
||||||
|
return "https://api.openai.com";
|
||||||
|
}
|
||||||
if (normalized === "gemini") {
|
if (normalized === "gemini") {
|
||||||
return "https://generativelanguage.googleapis.com";
|
return "https://generativelanguage.googleapis.com";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,4 +117,15 @@ describe("settingsRegistry helpers", () => {
|
|||||||
expect(settingsRegistry.rxresumeApiKey.envKey).toBe("RXRESUME_API_KEY");
|
expect(settingsRegistry.rxresumeApiKey.envKey).toBe("RXRESUME_API_KEY");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LLM provider parsing", () => {
|
||||||
|
it("normalizes the documented openai-compatible alias", () => {
|
||||||
|
expect(settingsRegistry.llmProvider.parse("openai-compatible")).toBe(
|
||||||
|
"openai_compatible",
|
||||||
|
);
|
||||||
|
expect(settingsRegistry.llmProvider.parse("OPENAI-COMPATIBLE")).toBe(
|
||||||
|
"openai_compatible",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,6 +26,12 @@ function parseBitBoolOrNull(raw: string | undefined): boolean | null {
|
|||||||
return raw === "true" || raw === "1";
|
return raw === "true" || raw === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLlmProviderOrNull(raw: string | undefined): string | null {
|
||||||
|
if (raw === undefined) return null;
|
||||||
|
const normalized = raw.trim().toLowerCase().replace(/-/g, "_");
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
function serializeNullableNumber(
|
function serializeNullableNumber(
|
||||||
value: number | null | undefined,
|
value: number | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
@ -66,16 +72,23 @@ export const settingsRegistry = {
|
|||||||
kind: "typed" as const,
|
kind: "typed" as const,
|
||||||
envKey: "LLM_PROVIDER",
|
envKey: "LLM_PROVIDER",
|
||||||
schema: z.preprocess(
|
schema: z.preprocess(
|
||||||
(v) => (v === "" ? null : v),
|
(v) => (typeof v === "string" ? normalizeLlmProviderOrNull(v) : v),
|
||||||
z
|
z
|
||||||
.enum(["openrouter", "lmstudio", "ollama", "openai", "gemini"])
|
.enum([
|
||||||
|
"openrouter",
|
||||||
|
"lmstudio",
|
||||||
|
"ollama",
|
||||||
|
"openai",
|
||||||
|
"openai_compatible",
|
||||||
|
"gemini",
|
||||||
|
])
|
||||||
.nullable(),
|
.nullable(),
|
||||||
),
|
),
|
||||||
default: (): string =>
|
default: (): string =>
|
||||||
typeof process !== "undefined"
|
typeof process !== "undefined"
|
||||||
? process.env.LLM_PROVIDER || "openrouter"
|
? normalizeLlmProviderOrNull(process.env.LLM_PROVIDER) || "openrouter"
|
||||||
: "openrouter",
|
: "openrouter",
|
||||||
parse: parseNonEmptyStringOrNull,
|
parse: normalizeLlmProviderOrNull,
|
||||||
serialize: (value: string | null | undefined): string | null =>
|
serialize: (value: string | null | undefined): string | null =>
|
||||||
value ?? null,
|
value ?? null,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user