mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 09:54:43 +10:00
Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:13:38 2026 +0200
chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies
commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:08:04 2026 +0200
fix: remove timestamp conflict guard
commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 12:11:51 2026 +0200
chore(release): v5.1.5
commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:35 2026 +0200
revert: compose.yml
commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:08 2026 +0200
refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086
commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:10:41 2026 +0200
refactor(pdf): simplify sidebar section filtering and update summary feature logic
commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:53:37 2026 +0200
chore: update pnpm-lock.yaml and turbo.json
commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:38:30 2026 +0200
fix(polyfill): add tested polyfill for Map Upsert methods
commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:23:29 2026 +0200
Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration
# Conflicts:
# packages/api/src/services/agent-url.ts
# packages/runtime-externals/package.json
commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:22:12 2026 +0200
chore: preserve branch changes before main sync
commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Mon May 18 07:50:28 2026 +0200
chore: lot of fixes for monorepo migration
commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 13:57:17 2026 +0200
chore: update knip version and refine web app routing with new SEO endpoints
commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 12:10:06 2026 +0200
refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint
commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:54:29 2026 +0200
chore: update dependencies and enhance PWA metadata in web app
commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:12:35 2026 +0200
docs: revise manifest-only pwa testing scope
commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:11:33 2026 +0200
docs: add manifest-only pwa design
commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:05:04 2026 +0200
chore(dev): simplify server proxy config
commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:50 2026 +0200
docs: add unsafe oauth redirect plan
commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:34 2026 +0200
feat(auth): add unsafe oauth redirect flag
commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:55:02 2026 +0200
docs: design unsafe oauth redirect flag
commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:22:04 2026 +0200
chore: update translation source paths
commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:09:25 2026 +0200
refactor(arch): react spa + hono migration
commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 01:10:47 2026 +0200
docs: add docker nightly tagging design
commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Thu May 14 20:05:44 2026 +0200
feat: migrate to hono spa server
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isDirectOpenAIProvider, supportsOpenAIWebSearch } from "./capabilities";
|
||||
|
||||
describe("AI provider capabilities", () => {
|
||||
it("identifies direct OpenAI base URL configs", () => {
|
||||
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "" })).toBe(true);
|
||||
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1/" })).toBe(true);
|
||||
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://example.com/v1" })).toBe(false);
|
||||
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1?proxy=1" })).toBe(false);
|
||||
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1#fragment" })).toBe(false);
|
||||
expect(isDirectOpenAIProvider({ provider: "openrouter", baseURL: "https://api.openai.com/v1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the OpenAI web search model predicate conservative", () => {
|
||||
const allowedModels = [
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-2026-04-23",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.5-pro-2026-04-23",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-2026-03-05",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-mini-2026-03-17",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5.4-nano-2026-03-17",
|
||||
"gpt-5.4-pro",
|
||||
"gpt-5.4-pro-2026-03-05",
|
||||
"gpt-5",
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-nano-2025-08-07",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-2025-04-14",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-mini-2025-04-14",
|
||||
"o4-mini",
|
||||
"o4-mini-2025-04-16",
|
||||
];
|
||||
const deniedModels = [
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-nano-2025-04-14",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-search-preview",
|
||||
"o1",
|
||||
"o1-2024-12-17",
|
||||
"o3",
|
||||
"o3-mini",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-5-codex",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.5-codex",
|
||||
"gpt-4x1-2025-04-14",
|
||||
"gpt-5x5-2026-04-23",
|
||||
"gpt-5x5-pro-2026-04-23",
|
||||
"custom-model",
|
||||
];
|
||||
|
||||
for (const model of allowedModels) {
|
||||
expect(supportsOpenAIWebSearch(model), model).toBe(true);
|
||||
}
|
||||
|
||||
for (const model of deniedModels) {
|
||||
expect(supportsOpenAIWebSearch(model), model).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { AIProvider } from "@reactive-resume/ai/types";
|
||||
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
|
||||
|
||||
type AiProviderCapabilityInput = {
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
baseURL?: string | null;
|
||||
};
|
||||
|
||||
function normalizeDirectOpenAIBaseUrl(baseURL: string) {
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
if (parsed.search || parsed.hash) return null;
|
||||
return parsed.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return baseURL.trim().replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function isDirectOpenAIProvider(input: Pick<AiProviderCapabilityInput, "provider" | "baseURL">) {
|
||||
if (input.provider !== "openai") return false;
|
||||
if (!input.baseURL?.trim()) return true;
|
||||
|
||||
const baseURL = normalizeDirectOpenAIBaseUrl(input.baseURL);
|
||||
if (!baseURL) return false;
|
||||
|
||||
return baseURL === normalizeDirectOpenAIBaseUrl(AI_PROVIDER_DEFAULT_BASE_URLS.openai);
|
||||
}
|
||||
|
||||
const OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS = new Set([
|
||||
// Snapshot from official OpenAI model docs on 2026-05-13. These model pages list Responses
|
||||
// API support and Responses web search support. Most are also explicit in installed
|
||||
// @ai-sdk/openai OpenAIResponsesModelId; gpt-5.5-pro is accepted through the SDK's string
|
||||
// model ID fallback and openai.responses("gpt-5.5-pro") runtime construction.
|
||||
// https://developers.openai.com/api/docs/models/gpt-5.5-pro
|
||||
"gpt-5.5-pro",
|
||||
// https://developers.openai.com/api/docs/models/gpt-5.5
|
||||
"gpt-5.5",
|
||||
// https://developers.openai.com/api/docs/models/gpt-5.4
|
||||
"gpt-5.4",
|
||||
// https://developers.openai.com/api/docs/models/gpt-5.4-mini
|
||||
"gpt-5.4-mini",
|
||||
// https://developers.openai.com/api/docs/models/gpt-5.4-nano
|
||||
"gpt-5.4-nano",
|
||||
// https://developers.openai.com/api/docs/models/gpt-5.4-pro
|
||||
"gpt-5.4-pro",
|
||||
// https://developers.openai.com/api/docs/models/gpt-5
|
||||
"gpt-5",
|
||||
// https://developers.openai.com/api/docs/models/gpt-5-mini
|
||||
"gpt-5-mini",
|
||||
// https://developers.openai.com/api/docs/models/gpt-5-nano
|
||||
"gpt-5-nano",
|
||||
// https://developers.openai.com/api/docs/models/gpt-4.1
|
||||
"gpt-4.1",
|
||||
// https://developers.openai.com/api/docs/models/gpt-4.1-mini
|
||||
"gpt-4.1-mini",
|
||||
// https://developers.openai.com/api/docs/guides/tools-web-search?api-mode=responses
|
||||
"o4-mini",
|
||||
]);
|
||||
|
||||
function isDateSnapshotForModel(model: string, modelId: string) {
|
||||
const snapshotPrefix = `${modelId}-`;
|
||||
if (!model.startsWith(snapshotPrefix)) return false;
|
||||
|
||||
const suffix = model.slice(snapshotPrefix.length);
|
||||
const [year, month, day] = suffix.split("-");
|
||||
|
||||
return (
|
||||
suffix.length === "YYYY-MM-DD".length &&
|
||||
year?.length === 4 &&
|
||||
month?.length === 2 &&
|
||||
day?.length === 2 &&
|
||||
[year, month, day].every((part) => /^\d+$/.test(part))
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsOpenAIWebSearch(model: string) {
|
||||
const normalized = model.trim().toLowerCase();
|
||||
if (!normalized || normalized.includes("codex")) return false;
|
||||
|
||||
if (OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS.has(normalized)) return true;
|
||||
|
||||
return Array.from(OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS).some((modelId) =>
|
||||
isDateSnapshotForModel(normalized, modelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsProviderNativeWebSearch(provider: AiProviderCapabilityInput) {
|
||||
return isDirectOpenAIProvider(provider) && supportsOpenAIWebSearch(provider.model);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
ENCRYPTION_SECRET: "test-secret-with-enough-entropy",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
|
||||
const {
|
||||
assertAgentEnvironment,
|
||||
decryptCredential,
|
||||
encryptCredential,
|
||||
fingerprintCredential,
|
||||
isAgentEnvironmentConfigured,
|
||||
redactEncryptedCredential,
|
||||
} = await import("./credentials");
|
||||
|
||||
describe("AI credential encryption", () => {
|
||||
it("encrypts and decrypts provider API keys without storing plaintext", () => {
|
||||
const encrypted = encryptCredential("sk-test-secret");
|
||||
|
||||
expect(encrypted.encryptedApiKey).not.toContain("sk-test-secret");
|
||||
expect(encrypted.apiKeyPreview).toBe("sk-t...cret");
|
||||
expect(decryptCredential(encrypted.encryptedApiKey)).toBe("sk-test-secret");
|
||||
});
|
||||
|
||||
it("generates salted non-revealable fingerprints", () => {
|
||||
const first = fingerprintCredential("sk-test-secret", "salt-a");
|
||||
const again = fingerprintCredential("sk-test-secret", "salt-a");
|
||||
const differentSalt = fingerprintCredential("sk-test-secret", "salt-b");
|
||||
|
||||
expect(first).toBe(again);
|
||||
expect(first).not.toBe(differentSalt);
|
||||
expect(first).not.toContain("sk-test-secret");
|
||||
});
|
||||
|
||||
it("redacts stored encrypted credential fields from API responses", () => {
|
||||
const encrypted = encryptCredential("sk-test-secret");
|
||||
|
||||
const redacted = redactEncryptedCredential({
|
||||
encryptedApiKey: encrypted.encryptedApiKey,
|
||||
apiKeySalt: encrypted.apiKeySalt,
|
||||
apiKeyHash: encrypted.apiKeyHash,
|
||||
apiKeyPreview: encrypted.apiKeyPreview,
|
||||
});
|
||||
|
||||
expect(redacted).toEqual({
|
||||
apiKeyFingerprint: encrypted.apiKeyHash,
|
||||
apiKeyPreview: encrypted.apiKeyPreview,
|
||||
});
|
||||
expect(JSON.stringify(redacted)).not.toContain(encrypted.encryptedApiKey);
|
||||
expect(JSON.stringify(redacted)).not.toContain(encrypted.apiKeySalt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI agent environment", () => {
|
||||
it("is available only when Redis and encryption secret are configured", () => {
|
||||
expect(isAgentEnvironmentConfigured()).toBe(true);
|
||||
expect(() => assertAgentEnvironment()).not.toThrow();
|
||||
|
||||
envMock.REDIS_URL = "";
|
||||
expect(isAgentEnvironmentConfigured()).toBe(false);
|
||||
expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE");
|
||||
|
||||
envMock.REDIS_URL = "redis://localhost:6379";
|
||||
envMock.ENCRYPTION_SECRET = "";
|
||||
expect(isAgentEnvironmentConfigured()).toBe(false);
|
||||
expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
const CIPHER = "aes-256-gcm";
|
||||
const CREDENTIAL_VERSION = "v1";
|
||||
const IV_BYTES = 12;
|
||||
const SALT_BYTES = 16;
|
||||
|
||||
type StoredCredentialFields = {
|
||||
encryptedApiKey: string;
|
||||
apiKeySalt: string;
|
||||
apiKeyHash: string;
|
||||
apiKeyPreview: string;
|
||||
};
|
||||
|
||||
type RedactedCredentialFields = {
|
||||
apiKeyFingerprint: string;
|
||||
apiKeyPreview: string;
|
||||
};
|
||||
|
||||
function getEncryptionSecret() {
|
||||
return env.ENCRYPTION_SECRET?.trim() ?? "";
|
||||
}
|
||||
|
||||
function getEncryptionKey() {
|
||||
const secret = getEncryptionSecret();
|
||||
if (!secret) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE");
|
||||
|
||||
return createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
function encode(value: Buffer) {
|
||||
return value.toString("base64url");
|
||||
}
|
||||
|
||||
function decode(value: string) {
|
||||
return Buffer.from(value, "base64url");
|
||||
}
|
||||
|
||||
function makePreview(apiKey: string) {
|
||||
const trimmed = apiKey.trim();
|
||||
if (trimmed.length <= 8) return "••••";
|
||||
|
||||
return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function fingerprintCredential(apiKey: string, salt: string) {
|
||||
return createHash("sha256").update(salt).update(":").update(apiKey).digest("hex");
|
||||
}
|
||||
|
||||
export function encryptCredential(apiKey: string): StoredCredentialFields {
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const salt = encode(randomBytes(SALT_BYTES));
|
||||
const cipher = createCipheriv(CIPHER, getEncryptionKey(), iv);
|
||||
|
||||
const ciphertext = Buffer.concat([cipher.update(apiKey, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
const payload = [CREDENTIAL_VERSION, encode(iv), encode(authTag), encode(ciphertext)].join(".");
|
||||
|
||||
return {
|
||||
encryptedApiKey: payload,
|
||||
apiKeySalt: salt,
|
||||
apiKeyHash: fingerprintCredential(apiKey, salt),
|
||||
apiKeyPreview: makePreview(apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptCredential(payload: string) {
|
||||
const [version, encodedIv, encodedAuthTag, encodedCiphertext] = payload.split(".");
|
||||
if (version !== CREDENTIAL_VERSION || !encodedIv || !encodedAuthTag || !encodedCiphertext) {
|
||||
throw new Error("INVALID_ENCRYPTED_CREDENTIAL");
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv(CIPHER, getEncryptionKey(), decode(encodedIv));
|
||||
decipher.setAuthTag(decode(encodedAuthTag));
|
||||
|
||||
return Buffer.concat([decipher.update(decode(encodedCiphertext)), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
export function redactEncryptedCredential(fields: StoredCredentialFields): RedactedCredentialFields {
|
||||
return {
|
||||
apiKeyFingerprint: fields.apiKeyHash,
|
||||
apiKeyPreview: fields.apiKeyPreview,
|
||||
};
|
||||
}
|
||||
|
||||
function isCredentialEncryptionConfigured() {
|
||||
return !!getEncryptionSecret();
|
||||
}
|
||||
|
||||
function isAgentStreamingConfigured() {
|
||||
return !!env.REDIS_URL?.trim();
|
||||
}
|
||||
|
||||
export function isAgentEnvironmentConfigured() {
|
||||
return isCredentialEncryptionConfigured() && isAgentStreamingConfigured();
|
||||
}
|
||||
|
||||
export function assertCredentialEncryptionConfigured() {
|
||||
if (!isCredentialEncryptionConfigured()) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE");
|
||||
}
|
||||
|
||||
export function assertAgentEnvironment() {
|
||||
if (!isAgentEnvironmentConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE");
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { type } from "@orpc/server";
|
||||
import { AISDKError } from "ai";
|
||||
import { flattenError, ZodError, z } from "zod";
|
||||
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { aiProvidersService } from "../ai-providers/service";
|
||||
import { resumeService } from "../resume/service";
|
||||
import { aiService, fileInputSchema } from "./service";
|
||||
|
||||
function isInvalidAiBaseUrlError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
|
||||
}
|
||||
|
||||
function isAiProviderGatewayError(error: unknown): boolean {
|
||||
return error instanceof AISDKError;
|
||||
}
|
||||
|
||||
function isCredentialEncryptionUnavailable(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE";
|
||||
}
|
||||
|
||||
function throwAiProviderGatewayError(): never {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
|
||||
}
|
||||
|
||||
function throwAiProviderConfigError(): never {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
|
||||
}
|
||||
|
||||
function throwCredentialEncryptionUnavailable(): never {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
function throwResumeStructureError(error: ZodError): never {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
|
||||
async function getRunnableProvider(userId: string, aiProviderId?: string) {
|
||||
const provider = aiProviderId
|
||||
? await aiProvidersService.getRunnableById({ id: aiProviderId, userId })
|
||||
: await aiProvidersService.getDefaultRunnable({ userId });
|
||||
|
||||
if (!provider) throw new ORPCError("BAD_REQUEST", { message: "No tested AI provider is available." });
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
export const aiRouter = {
|
||||
parsePdf: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/parse-pdf",
|
||||
tags: ["AI"],
|
||||
operationId: "parseResumePdf",
|
||||
summary: "Parse a PDF file into resume data",
|
||||
description:
|
||||
"Extracts structured resume data from a PDF file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials. Returns a complete ResumeData object. Requires authentication.",
|
||||
successDescription: "The PDF was successfully parsed into structured resume data.",
|
||||
})
|
||||
.input(z.object({ aiProviderId: z.string().optional(), file: fileInputSchema }))
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.handler(async ({ context, input }): Promise<ResumeData> => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
return await aiService.parsePdf({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
file: input.file,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
parseDocx: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/parse-docx",
|
||||
tags: ["AI"],
|
||||
operationId: "parseResumeDocx",
|
||||
summary: "Parse a DOCX file into resume data",
|
||||
description:
|
||||
"Extracts structured resume data from a DOCX or DOC file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials and the document's media type. Returns a complete ResumeData object. Requires authentication.",
|
||||
successDescription: "The DOCX was successfully parsed into structured resume data.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
aiProviderId: z.string().optional(),
|
||||
file: fileInputSchema,
|
||||
mediaType: z.enum([
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
return await aiService.parseDocx({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
mediaType: input.mediaType,
|
||||
file: input.file,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
chat: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/chat",
|
||||
tags: ["AI"],
|
||||
operationId: "aiChat",
|
||||
summary: "Chat with AI to modify resume",
|
||||
description:
|
||||
"Streams a chat response from the configured AI provider. The LLM can call the propose_resume_patches tool to generate JSON Patch proposals for explicit user approval. Requires authentication and AI provider credentials.",
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
aiProviderId?: string;
|
||||
messages: UIMessage[];
|
||||
resumeId: string;
|
||||
}>(),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const [provider, resume] = await Promise.all([
|
||||
getRunnableProvider(context.user.id, input.aiProviderId),
|
||||
resumeService.getById({ id: input.resumeId, userId: context.user.id }),
|
||||
]);
|
||||
|
||||
return await aiService.chat({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
messages: input.messages,
|
||||
resumeData: resume.data,
|
||||
resumeUpdatedAt: resume.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
analyzeResume: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/analyze-resume",
|
||||
tags: ["AI"],
|
||||
operationId: "analyzeResume",
|
||||
summary: "Analyze resume and persist latest analysis",
|
||||
description:
|
||||
"Uses AI to analyze the current resume and returns a structured analysis with scorecard, strengths, and improvement suggestions. The latest analysis is persisted and can be fetched later. Requires authentication and AI credentials.",
|
||||
successDescription: "Structured resume analysis returned and persisted successfully.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
aiProviderId: z.string().optional(),
|
||||
resumeId: z.string(),
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.output(storedResumeAnalysisSchema)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const [provider, resume] = await Promise.all([
|
||||
getRunnableProvider(context.user.id, input.aiProviderId),
|
||||
resumeService.getById({ id: input.resumeId, userId: context.user.id }),
|
||||
]);
|
||||
const analysis = await aiService.analyzeResume({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
resumeData: resume.data,
|
||||
});
|
||||
|
||||
return await resumeService.analysis.upsert({
|
||||
id: input.resumeId,
|
||||
userId: context.user.id,
|
||||
analysis: {
|
||||
...analysis,
|
||||
updatedAt: new Date(),
|
||||
modelMeta: { provider: provider.provider, model: provider.model },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume analysis structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertToModelMessages, modelMessageSchema } from "ai";
|
||||
|
||||
describe("AI chat service", () => {
|
||||
it("keeps proposal tool history valid for follow-up chat messages", async () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Add draft references." }],
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{
|
||||
type: "tool-propose_resume_patches",
|
||||
toolCallId: "call-1",
|
||||
state: "output-available",
|
||||
input: {
|
||||
proposals: [
|
||||
{
|
||||
title: "Add draft references",
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "/sections/references/items",
|
||||
value: [
|
||||
{ id: "reference-1", name: "Jane Mitchell" },
|
||||
{ id: "reference-2", name: "Marcus Chen" },
|
||||
{ id: "reference-3", name: "Olivia Ramirez" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
proposals: [
|
||||
{
|
||||
id: "proposal-1",
|
||||
title: "Add draft references",
|
||||
baseUpdatedAt: "2026-05-10T06:38:27.093Z",
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "/sections/references/items",
|
||||
value: [
|
||||
{ id: "reference-1", name: "Jane Mitchell" },
|
||||
{ id: "reference-2", name: "Marcus Chen" },
|
||||
{ id: "reference-3", name: "Olivia Ramirez" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "I prepared draft reference changes for review." }],
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Reduce it down to the first two." }],
|
||||
},
|
||||
];
|
||||
|
||||
const modelMessages = await convertToModelMessages(messages);
|
||||
|
||||
expect(modelMessages.map((message) => message.role)).toEqual(["user", "assistant", "tool", "assistant", "user"]);
|
||||
expect(JSON.stringify(modelMessages)).toContain("proposal-1");
|
||||
expect(JSON.stringify(modelMessages)).toContain("/sections/references/items");
|
||||
expect(JSON.stringify(modelMessages)).toContain("tool-result");
|
||||
|
||||
for (const message of modelMessages) {
|
||||
expect(modelMessageSchema.safeParse(message).success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { AIProvider } from "@reactive-resume/ai/types";
|
||||
import type { ResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ModelMessage, UIMessage } from "ai";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { streamToEventIterator } from "@orpc/server";
|
||||
import { convertToModelMessages, createGateway, generateText, Output, stepCountIs, streamText, tool } from "ai";
|
||||
import { createOllama } from "ollama-ai-provider-v2";
|
||||
import { match } from "ts-pattern";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
analyzeResumeSystemPrompt as analyzeResumeSystemPromptTemplate,
|
||||
chatSystemPromptTemplate,
|
||||
docxParserSystemPrompt,
|
||||
docxParserUserPrompt,
|
||||
pdfParserSystemPrompt,
|
||||
pdfParserUserPrompt,
|
||||
} from "@reactive-resume/ai/prompts";
|
||||
import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template";
|
||||
import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize";
|
||||
import {
|
||||
normalizeResumePatchProposals,
|
||||
resumePatchProposalToolInputSchema,
|
||||
resumePatchProposalToolOutputSchema,
|
||||
} from "@reactive-resume/ai/tools/patch-proposal";
|
||||
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
import { applyResumePatches } from "@reactive-resume/resume/patch";
|
||||
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
|
||||
import { supportsProviderNativeWebSearch } from "./capabilities";
|
||||
import { resolveAiBaseUrl } from "./url-policy";
|
||||
|
||||
const aiExtractionTemplate = buildAiExtractionTemplate();
|
||||
|
||||
function logAndRethrow(context: string, error: unknown): never {
|
||||
if (error instanceof Error) {
|
||||
console.error(`${context}:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(`${context}:`, error);
|
||||
throw new Error(`An unknown error occurred during ${context}.`);
|
||||
}
|
||||
|
||||
function parseAndValidateResumeJson(resultText: string): ResumeData {
|
||||
const { data, diagnostics } = sanitizeAndParseResumeJson(resultText);
|
||||
|
||||
if (diagnostics.coercions.length === 0 && diagnostics.droppedSectionItems.length === 0) return data;
|
||||
|
||||
const droppedBySection = diagnostics.droppedSectionItems.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.section] = (acc[item.section] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.info("AI resume sanitization diagnostics", {
|
||||
coercions: diagnostics.coercions.length,
|
||||
droppedBySection,
|
||||
salvageApplied: diagnostics.salvageApplied,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
type GetModelInput = {
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
baseURL?: string;
|
||||
};
|
||||
|
||||
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
|
||||
|
||||
export function getModel(input: GetModelInput) {
|
||||
const { provider, model, apiKey } = input;
|
||||
const baseURL = resolveAiBaseUrl(input);
|
||||
|
||||
return match(provider)
|
||||
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
|
||||
.with("anthropic", () => createAnthropic({ apiKey, baseURL }).languageModel(model))
|
||||
.with("gemini", () => createGoogleGenerativeAI({ apiKey, baseURL }).languageModel(model))
|
||||
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
|
||||
.with("openrouter", () => createOpenAICompatible({ name: "openrouter", apiKey, baseURL }).languageModel(model))
|
||||
.with("openai-compatible", () =>
|
||||
createOpenAICompatible({ name: "openai-compatible", apiKey, baseURL }).languageModel(model),
|
||||
)
|
||||
.with("ollama", () => {
|
||||
const ollama = createOllama({
|
||||
name: "ollama",
|
||||
baseURL,
|
||||
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
|
||||
});
|
||||
|
||||
return ollama.languageModel(model);
|
||||
})
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function getAgentModel(input: GetModelInput) {
|
||||
if (!supportsProviderNativeWebSearch(input)) return getModel(input);
|
||||
|
||||
return createOpenAI({ apiKey: input.apiKey, baseURL: resolveAiBaseUrl(input) }).responses(input.model);
|
||||
}
|
||||
|
||||
const aiCredentialsSchema = z.object({
|
||||
provider: aiProviderSchema,
|
||||
model: z.string().trim().min(1),
|
||||
apiKey: z.string().trim().min(1),
|
||||
baseURL: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
export const fileInputSchema = z.object({
|
||||
name: z.string(),
|
||||
data: z.string().max(MAX_AI_FILE_BASE64_CHARS, "File is too large. Maximum size is 10MB."),
|
||||
});
|
||||
|
||||
type TestConnectionInput = z.infer<typeof aiCredentialsSchema>;
|
||||
|
||||
export async function testConnection(input: TestConnectionInput): Promise<boolean> {
|
||||
const RESPONSE_OK = "1";
|
||||
|
||||
const result = await generateText({
|
||||
model: getModel(input),
|
||||
output: Output.choice({ options: [RESPONSE_OK] }),
|
||||
messages: [{ role: "user", content: `Respond only with JSON Object: { "result": "${RESPONSE_OK}" }` }],
|
||||
});
|
||||
|
||||
return result.output === RESPONSE_OK;
|
||||
}
|
||||
|
||||
type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
file: z.infer<typeof fileInputSchema>;
|
||||
};
|
||||
|
||||
type BuildResumeParsingMessagesInput = {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
file: z.infer<typeof fileInputSchema>;
|
||||
mediaType: string;
|
||||
};
|
||||
|
||||
function buildResumeParsingMessages({
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
file,
|
||||
mediaType,
|
||||
}: BuildResumeParsingMessagesInput): ModelMessage[] {
|
||||
return [
|
||||
{
|
||||
role: "system",
|
||||
content: `${systemPrompt}\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n${JSON.stringify(aiExtractionTemplate, null, 2)}`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: userPrompt },
|
||||
{ type: "file", data: file.data, mediaType, filename: file.name },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
|
||||
const model = getModel(input);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
messages: buildResumeParsingMessages({
|
||||
systemPrompt: pdfParserSystemPrompt,
|
||||
userPrompt: pdfParserUserPrompt,
|
||||
file: input.file,
|
||||
mediaType: "application/pdf",
|
||||
}),
|
||||
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
|
||||
|
||||
return parseAndValidateResumeJson(result.text);
|
||||
}
|
||||
|
||||
type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
file: z.infer<typeof fileInputSchema>;
|
||||
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
};
|
||||
|
||||
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
|
||||
const model = getModel(input);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
messages: buildResumeParsingMessages({
|
||||
systemPrompt: docxParserSystemPrompt,
|
||||
userPrompt: docxParserUserPrompt,
|
||||
file: input.file,
|
||||
mediaType: input.mediaType,
|
||||
}),
|
||||
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
|
||||
|
||||
return parseAndValidateResumeJson(result.text);
|
||||
}
|
||||
|
||||
function buildChatSystemPrompt(resumeData: ResumeData): string {
|
||||
return chatSystemPromptTemplate.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2));
|
||||
}
|
||||
|
||||
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
messages: UIMessage[];
|
||||
resumeData: ResumeData;
|
||||
resumeUpdatedAt: Date;
|
||||
};
|
||||
|
||||
async function chat(input: ChatInput) {
|
||||
const model = getModel(input);
|
||||
const systemPrompt = buildChatSystemPrompt(input.resumeData);
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
messages: await convertToModelMessages(input.messages),
|
||||
tools: {
|
||||
propose_resume_patches: tool({
|
||||
description:
|
||||
"Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.",
|
||||
inputSchema: resumePatchProposalToolInputSchema,
|
||||
outputSchema: resumePatchProposalToolOutputSchema,
|
||||
execute: async (toolInput) => {
|
||||
const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt);
|
||||
|
||||
for (const proposal of proposals) {
|
||||
applyResumePatches(input.resumeData, proposal.operations);
|
||||
}
|
||||
|
||||
return { proposals };
|
||||
},
|
||||
}),
|
||||
},
|
||||
stopWhen: stepCountIs(3),
|
||||
});
|
||||
|
||||
return streamToEventIterator(result.toUIMessageStream());
|
||||
}
|
||||
|
||||
type AnalyzeResumeInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
resumeData: ResumeData;
|
||||
};
|
||||
|
||||
function buildAnalyzeResumeSystemPrompt(resumeData: ResumeData): string {
|
||||
return `${analyzeResumeSystemPromptTemplate}\n\n## Resume Data\n\n${JSON.stringify(resumeData, null, 2)}`;
|
||||
}
|
||||
|
||||
async function analyzeResume(input: AnalyzeResumeInput): Promise<ResumeAnalysis> {
|
||||
const model = getModel(input);
|
||||
const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
output: Output.object({ schema: resumeAnalysisOutputSchema }),
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (result.output == null) {
|
||||
throw new Error("AI returned no structured analysis output.");
|
||||
}
|
||||
|
||||
return resumeAnalysisSchema.parse(result.output);
|
||||
}
|
||||
|
||||
export const aiService = {
|
||||
analyzeResume,
|
||||
chat,
|
||||
parseDocx,
|
||||
parsePdf,
|
||||
testConnection,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
|
||||
const { resolveAiBaseUrl } = await import("./url-policy");
|
||||
|
||||
describe("AI provider base URL policy", () => {
|
||||
it("allows public HTTPS provider URLs", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
|
||||
|
||||
expect(resolveAiBaseUrl({ provider: "openai", baseURL: "https://api.openai.com/v1" })).toBe(
|
||||
"https://api.openai.com/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks private and non-HTTPS provider URLs by default", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
|
||||
|
||||
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://localhost:11434/v1" })).toThrow(
|
||||
"INVALID_AI_BASE_URL",
|
||||
);
|
||||
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://example.com/v1" })).toThrow(
|
||||
"INVALID_AI_BASE_URL",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows private and non-HTTPS provider URLs when explicitly enabled", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
|
||||
|
||||
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://localhost:11434/v1" })).toBe(
|
||||
"http://localhost:11434/v1",
|
||||
);
|
||||
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://10.0.0.5/v1" })).toBe(
|
||||
"https://10.0.0.5/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-HTTP schemes even when unsafe provider URLs are enabled", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
|
||||
|
||||
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "file:///etc/passwd" })).toThrow(
|
||||
"INVALID_AI_BASE_URL",
|
||||
);
|
||||
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "ftp://example.com/v1" })).toThrow(
|
||||
"INVALID_AI_BASE_URL",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AIProvider } from "@reactive-resume/ai/types";
|
||||
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security.node";
|
||||
|
||||
type ResolveAiBaseUrlInput = {
|
||||
provider: AIProvider;
|
||||
baseURL?: string | null;
|
||||
};
|
||||
|
||||
function assertSafeUrl(input: string, errorCode: string, options?: { allowUnsafe?: boolean }) {
|
||||
const parsed = parseUrl(input);
|
||||
if (!parsed) throw new Error(errorCode);
|
||||
if (parsed.username || parsed.password) throw new Error(errorCode);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(errorCode);
|
||||
|
||||
if (!options?.allowUnsafe) {
|
||||
if (parsed.protocol !== "https:") throw new Error(errorCode);
|
||||
if (isPrivateOrLoopbackHost(parsed.hostname)) throw new Error(errorCode);
|
||||
}
|
||||
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function resolveAiBaseUrl(input: ResolveAiBaseUrlInput) {
|
||||
const baseURL = input.baseURL?.trim() || AI_PROVIDER_DEFAULT_BASE_URLS[input.provider];
|
||||
if (!baseURL) throw new Error("INVALID_AI_BASE_URL");
|
||||
|
||||
return assertSafeUrl(baseURL, "INVALID_AI_BASE_URL", { allowUnsafe: env.FLAG_ALLOW_UNSAFE_AI_BASE_URL });
|
||||
}
|
||||
Reference in New Issue
Block a user