own provider update + retry buggy response
This commit is contained in:
@@ -42,6 +42,9 @@ export const config = {
|
||||
openaiApiKey: env("OPENAI_API_KEY", ""),
|
||||
openaiEnabled: envBool("OPENAI_ENABLED", false),
|
||||
|
||||
geminiApiKey: env("GEMINI_API_KEY", ""),
|
||||
geminiEnabled: envBool("GEMINI_ENABLED", false),
|
||||
|
||||
glmApiKey: env("GLM_API_KEY", ""),
|
||||
glmEnabled: envBool("GLM_ENABLED", false),
|
||||
// Zhipu has two platforms: international (api.z.ai, default) and
|
||||
|
||||
@@ -114,6 +114,13 @@ export function getDb(): Database {
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_provider_keys(
|
||||
user_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, provider)
|
||||
);
|
||||
`);
|
||||
// Email column added after the initial schema — keep idempotent.
|
||||
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
|
||||
@@ -209,6 +216,7 @@ export function deleteUser(id: string): void {
|
||||
for (const c of convs) txn(c.id);
|
||||
d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
|
||||
d.query("DELETE FROM resets WHERE user_id = ?").run(id);
|
||||
d.query("DELETE FROM user_provider_keys WHERE user_id = ?").run(id);
|
||||
d.query("DELETE FROM users WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
|
||||
+45
-12
@@ -1,14 +1,25 @@
|
||||
import { config } from "../config.ts";
|
||||
import { ApiDriverBase } from "./apiBase.ts";
|
||||
|
||||
/**
|
||||
* Concrete API drivers read their key from the server env by default.
|
||||
* Pass `apiKeyOverride` to build a per-user (BYOK) instance instead: the
|
||||
* driver is then enabled whenever the override key is non-empty, ignoring
|
||||
* the global `*_ENABLED` flag. See `src/userProviders.ts`.
|
||||
*/
|
||||
function globalEnabled(flag: boolean, key: string): boolean {
|
||||
return flag && key.length > 0;
|
||||
}
|
||||
|
||||
/** Mistral API driver. Global priority #2 (after local ollama). */
|
||||
export class MistralDriver extends ApiDriverBase {
|
||||
readonly name = "mistral";
|
||||
constructor() {
|
||||
constructor(apiKeyOverride?: string) {
|
||||
const apiKey = apiKeyOverride ?? config.mistralApiKey;
|
||||
super({
|
||||
baseUrl: "https://api.mistral.ai/v1",
|
||||
apiKey: config.mistralApiKey,
|
||||
enabled: config.mistralEnabled,
|
||||
apiKey,
|
||||
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.mistralEnabled, apiKey),
|
||||
defaultModels: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest"],
|
||||
});
|
||||
}
|
||||
@@ -17,11 +28,12 @@ export class MistralDriver extends ApiDriverBase {
|
||||
/** DeepSeek API driver. Priority within APIs: DeepSeek first. */
|
||||
export class DeepSeekDriver extends ApiDriverBase {
|
||||
readonly name = "deepseek";
|
||||
constructor() {
|
||||
constructor(apiKeyOverride?: string) {
|
||||
const apiKey = apiKeyOverride ?? config.deepseekApiKey;
|
||||
super({
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
apiKey: config.deepseekApiKey,
|
||||
enabled: config.deepseekEnabled,
|
||||
apiKey,
|
||||
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.deepseekEnabled, apiKey),
|
||||
defaultModels: ["deepseek-chat", "deepseek-reasoner"],
|
||||
});
|
||||
}
|
||||
@@ -30,11 +42,12 @@ export class DeepSeekDriver extends ApiDriverBase {
|
||||
/** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */
|
||||
export class AnthropicDriver extends ApiDriverBase {
|
||||
readonly name = "anthropic";
|
||||
constructor() {
|
||||
constructor(apiKeyOverride?: string) {
|
||||
const apiKey = apiKeyOverride ?? config.anthropicApiKey;
|
||||
super({
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
apiKey: config.anthropicApiKey,
|
||||
enabled: config.anthropicEnabled,
|
||||
apiKey,
|
||||
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.anthropicEnabled, apiKey),
|
||||
defaultModels: ["claude-sonnet-4-20250514", "claude-3-7-sonnet-latest"],
|
||||
});
|
||||
}
|
||||
@@ -43,16 +56,36 @@ export class AnthropicDriver extends ApiDriverBase {
|
||||
/** OpenAI API driver. Priority: last among APIs. */
|
||||
export class OpenAIDriver extends ApiDriverBase {
|
||||
readonly name = "openai";
|
||||
constructor() {
|
||||
constructor(apiKeyOverride?: string) {
|
||||
const apiKey = apiKeyOverride ?? config.openaiApiKey;
|
||||
super({
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: config.openaiApiKey,
|
||||
enabled: config.openaiEnabled,
|
||||
apiKey,
|
||||
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.openaiEnabled, apiKey),
|
||||
defaultModels: ["gpt-4o", "gpt-4o-mini"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini driver (Google AI, OpenAI-compatible endpoint). Priority: after GLM.
|
||||
* Docs: https://ai.google.dev/gemini-api/docs/openai — key from
|
||||
* https://aistudio.google.com/apikey (server: GEMINI_API_KEY, or per-user
|
||||
* BYOK in settings).
|
||||
*/
|
||||
export class GeminiDriver extends ApiDriverBase {
|
||||
readonly name = "gemini";
|
||||
constructor(apiKeyOverride?: string) {
|
||||
const apiKey = apiKeyOverride ?? config.geminiApiKey;
|
||||
super({
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
apiKey,
|
||||
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.geminiEnabled, apiKey),
|
||||
defaultModels: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GLM driver (Zhipu AI, OpenAI-compatible endpoint). Priority: after Mistral.
|
||||
* Docs: https://docs.z.ai — international base is api.z.ai, mainland China
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { AnthropicDriver, DeepSeekDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
|
||||
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
|
||||
import { OllamaDriver } from "./ollama.ts";
|
||||
import { createArcaicDrivers } from "./arcaic.ts";
|
||||
import type { DriverModel, LLMDriver } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Global driver priority (per spec):
|
||||
* 1. ollama (local) -> 2. mistral -> 3. glm
|
||||
* -> 4. arcaic-openai -> 5. arcaic-gemini -> 6. arcaic-qwen
|
||||
* -> 7. deepseek -> 8. anthropic -> 9. openai
|
||||
* 1. ollama (local) -> 2. mistral -> 3. glm -> 4. gemini
|
||||
* -> 5. arcaic-openai -> 6. arcaic-gemini -> 7. arcaic-qwen
|
||||
* -> 8. deepseek -> 9. anthropic -> 10. openai
|
||||
*/
|
||||
const PRIORITY = [
|
||||
"ollama",
|
||||
"mistral",
|
||||
"glm",
|
||||
"gemini",
|
||||
"arcaic-openai",
|
||||
"arcaic-gemini",
|
||||
"arcaic-qwen",
|
||||
@@ -37,6 +38,7 @@ export class DriverRegistry {
|
||||
new OllamaDriver(),
|
||||
new MistralDriver(),
|
||||
new GlmDriver(),
|
||||
new GeminiDriver(),
|
||||
...createArcaicDrivers(),
|
||||
new DeepSeekDriver(),
|
||||
new AnthropicDriver(),
|
||||
|
||||
+73
-3
@@ -65,8 +65,7 @@ import {
|
||||
listAccessRequests,
|
||||
setAccessStatus,
|
||||
type AccessStatus,
|
||||
} from "./access.ts";
|
||||
import { broadcastAccessLive, subscribeAccessLive } from "./accessLive.ts";
|
||||
} from "./access.ts";import { broadcastAccessLive, subscribeAccessLive } from "./accessLive.ts";
|
||||
import {
|
||||
REPORT_REASONS,
|
||||
REPORT_STATUSES,
|
||||
@@ -87,6 +86,17 @@ import {
|
||||
recordModelUse,
|
||||
setModelPolicyEntry,
|
||||
} from "./modelPolicy.ts";
|
||||
import {
|
||||
buildUserProviderDriver,
|
||||
deleteUserProviderKey,
|
||||
getUserProviderKey,
|
||||
isUserProviderId,
|
||||
listUserProviders,
|
||||
listUserProviderModels,
|
||||
setUserProviderKey,
|
||||
validProviderKey,
|
||||
type UserProviderId,
|
||||
} from "./userProviders.ts";
|
||||
import type { ConversationRow } from "./db.ts";
|
||||
import type { OllamaDriver } from "./drivers/ollama.ts";
|
||||
import { DriverRegistry } from "./drivers/registry.ts";
|
||||
@@ -668,6 +678,35 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
return json({ enabled: false });
|
||||
}
|
||||
|
||||
// --- self-service: personal LLM providers (BYOK) ---
|
||||
// Keys are stored server-side and never returned: GET only exposes
|
||||
// presence + last4. Connected providers add their models to this
|
||||
// user's /api/models list and /api/chat honors the personal key.
|
||||
if (path === "/api/me/providers" && req.method === "GET") {
|
||||
return json({ providers: listUserProviders(user.id) });
|
||||
}
|
||||
|
||||
const providerMatch = path.match(/^\/api\/me\/providers\/([a-z]{3,12})$/);
|
||||
if (providerMatch) {
|
||||
const pid = providerMatch[1]!;
|
||||
if (!isUserProviderId(pid)) {
|
||||
return json({ error: "unknown provider (openai|anthropic|deepseek|gemini)" }, 400);
|
||||
}
|
||||
const provider = pid as UserProviderId;
|
||||
if (req.method === "PUT") {
|
||||
const { ok, body } = await readJson(req);
|
||||
const key = ok ? validProviderKey(body.apiKey) : null;
|
||||
if (!key) return json({ error: "apiKey: 8-256 chars, no whitespace" }, 400);
|
||||
setUserProviderKey(user.id, provider, key);
|
||||
return json({ providers: listUserProviders(user.id) });
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
deleteUserProviderKey(user.id, provider);
|
||||
return json({ providers: listUserProviders(user.id) });
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
// --- admin: user management (No-KYC accounts, keys issued by admin) ---
|
||||
if (path === "/api/admin/users" && req.method === "GET") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
@@ -1232,6 +1271,21 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
url.searchParams.get("refresh") ?? url.searchParams.get("force") ?? "",
|
||||
);
|
||||
const models = await registry.listAllModelsCached(wantRefresh && admin);
|
||||
// Personal providers (BYOK): merge the caller's own-key models.
|
||||
// Same ids as the global drivers, so already-listed ones are skipped
|
||||
// and the model picker groups them with their driver.
|
||||
try {
|
||||
const personal = await listUserProviderModels(user.id);
|
||||
const seen = new Set(models.map((m) => m.id));
|
||||
for (const m of personal) {
|
||||
if (!seen.has(m.id)) {
|
||||
seen.add(m.id);
|
||||
models.push(m);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// personal listing is best-effort — global models still served
|
||||
}
|
||||
if (admin) return json({ models });
|
||||
// Users never see admin-disabled models (nor select them — /api/chat
|
||||
// enforces the same policy server-side).
|
||||
@@ -1284,8 +1338,24 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
let driver, model: string;
|
||||
try {
|
||||
({ driver, model } = registry.splitId(body.model));
|
||||
// BYOK precedence: when the caller connected this provider with
|
||||
// their own key, the request is billed to them, not the platform.
|
||||
if (isUserProviderId(driver.name)) {
|
||||
const personalKey = getUserProviderKey(user.id, driver.name);
|
||||
if (personalKey) driver = buildUserProviderDriver(driver.name, personalKey);
|
||||
}
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, 400);
|
||||
// Unknown/disabled global driver — maybe the caller connected it
|
||||
// personally (e.g. platform key unset, user key set).
|
||||
const fullId = body.model as string;
|
||||
const idx = fullId.indexOf(":");
|
||||
const pname = idx === -1 ? "" : fullId.slice(0, idx);
|
||||
const sub = idx === -1 ? "" : fullId.slice(idx + 1);
|
||||
if (!isUserProviderId(pname)) return json({ error: (e as Error).message }, 400);
|
||||
const personalKey = getUserProviderKey(user.id, pname);
|
||||
if (!personalKey || !sub) return json({ error: (e as Error).message }, 400);
|
||||
driver = buildUserProviderDriver(pname, personalKey);
|
||||
model = sub;
|
||||
}
|
||||
// Per-model access policy (kill-switch + per-user rate limits).
|
||||
// Admins bypass; attempts count even when generation later fails.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { getDb } from "./db.ts";
|
||||
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, OpenAIDriver } from "./drivers/apis.ts";
|
||||
import type { DriverModel, LLMDriver } from "./drivers/types.ts";
|
||||
|
||||
/**
|
||||
* Personal LLM providers (BYOK): users attach their own API keys in
|
||||
* settings, and the matching models show up in their model menu.
|
||||
*
|
||||
* Security notes (do not weaken):
|
||||
* - Keys are stored reversibly in SQLite (they must be sent upstream) and
|
||||
* are NEVER returned to any client — GET only exposes presence + last4.
|
||||
* - Never log or print keys. Key material only travels server -> provider.
|
||||
* - Scoping is per account: every helper takes userId and all chat/model
|
||||
* routes resolve keys for the caller only.
|
||||
*/
|
||||
|
||||
// Providers a user may connect themselves. Keep in sync with the settings
|
||||
// UI (frontend PROVIDER_META) and the docs.
|
||||
export const USER_PROVIDER_IDS = ["openai", "anthropic", "deepseek", "gemini"] as const;
|
||||
export type UserProviderId = (typeof USER_PROVIDER_IDS)[number];
|
||||
|
||||
export const USER_PROVIDER_LABELS: Record<UserProviderId, string> = {
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
deepseek: "DeepSeek",
|
||||
gemini: "Gemini",
|
||||
};
|
||||
|
||||
export function isUserProviderId(v: string): v is UserProviderId {
|
||||
return (USER_PROVIDER_IDS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
/** User-supplied key validation: trimmed printable secret, 8–256 chars. */
|
||||
export function validProviderKey(v: unknown): string | null {
|
||||
if (typeof v !== "string") return null;
|
||||
const t = v.trim();
|
||||
if (t.length < 8 || t.length > 256) return null;
|
||||
if (/[\s]/.test(t)) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
export interface UserProviderStatus {
|
||||
provider: UserProviderId;
|
||||
label: string;
|
||||
hasKey: boolean;
|
||||
/** Last 4 chars of the stored key (for recognition), or null when unset. */
|
||||
last4: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function getUserProviderKey(userId: string, provider: UserProviderId): string | null {
|
||||
const r = getDb()
|
||||
.query("SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?")
|
||||
.get(userId, provider) as { api_key: string } | null;
|
||||
return r?.api_key ?? null;
|
||||
}
|
||||
|
||||
export function listUserProviders(userId: string): UserProviderStatus[] {
|
||||
const rows = getDb()
|
||||
.query("SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?")
|
||||
.all(userId) as { provider: string; api_key: string; updated_at: number }[];
|
||||
const byId = new Map(rows.map((r) => [r.provider, r]));
|
||||
return USER_PROVIDER_IDS.map((p) => {
|
||||
const r = byId.get(p);
|
||||
return {
|
||||
provider: p,
|
||||
label: USER_PROVIDER_LABELS[p],
|
||||
hasKey: !!r,
|
||||
last4: r ? r.api_key.slice(-4) : null,
|
||||
updatedAt: r?.updated_at ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): void {
|
||||
getDb()
|
||||
.query(
|
||||
"INSERT INTO user_provider_keys (user_id, provider, api_key, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at",
|
||||
)
|
||||
.run(userId, provider, apiKey, Date.now());
|
||||
}
|
||||
|
||||
export function deleteUserProviderKey(userId: string, provider: UserProviderId): void {
|
||||
getDb().query("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?").run(userId, provider);
|
||||
}
|
||||
|
||||
/** Build a per-user driver instance authenticated with the user's own key. */
|
||||
export function buildUserProviderDriver(provider: UserProviderId, apiKey: string): LLMDriver {
|
||||
switch (provider) {
|
||||
case "openai":
|
||||
return new OpenAIDriver(apiKey);
|
||||
case "anthropic":
|
||||
return new AnthropicDriver(apiKey);
|
||||
case "deepseek":
|
||||
return new DeepSeekDriver(apiKey);
|
||||
case "gemini":
|
||||
return new GeminiDriver(apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List models reachable with the user's own keys. Never throws: a failing
|
||||
* provider is skipped (its driver falls back to a static list, and only a
|
||||
* total failure is swallowed here).
|
||||
*/
|
||||
export async function listUserProviderModels(userId: string): Promise<DriverModel[]> {
|
||||
const out: DriverModel[] = [];
|
||||
const jobs = USER_PROVIDER_IDS.map(async (p) => {
|
||||
const key = getUserProviderKey(userId, p);
|
||||
if (!key) return;
|
||||
try {
|
||||
out.push(...(await buildUserProviderDriver(p, key).listModels()));
|
||||
} catch (e) {
|
||||
console.error(`[providers] user ${p} listModels failed:`, (e as Error).message);
|
||||
}
|
||||
});
|
||||
await Promise.all(jobs);
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user