Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,31 @@ describe("assertComposeBackendReady", () => {
expect(mockGetUserAiCredentialPlaintext).not.toHaveBeenCalled();
});

it("allows BYOK backend without static env model provider mismatch (#972)", async () => {
it("rejects non-Google BYOK for wiki-compose-research (fixed Gemini model)", async () => {
await expect(
assertComposeBackendReady({
backend: "user_openai",
graphId: "wiki-compose-research",
userId: "u1",
tier: "free",
db,
}),
).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("user_google"),
});
expect(mockGetUserAiCredentialPlaintext).not.toHaveBeenCalled();
});

it("allows user_google BYOK for wiki-compose when credential exists", async () => {
await assertComposeBackendReady({
backend: "user_openai",
graphId: "wiki-compose-research",
backend: "user_google",
graphId: "wiki-compose",
userId: "u1",
tier: "free",
db,
});
expect(mockGetUserAiCredentialPlaintext).toHaveBeenCalledWith("u1", "openai", db);
expect(mockGetUserAiCredentialPlaintext).toHaveBeenCalledWith("u1", "google", db);
});

it("skips credential check for model-less graphs (wiki-maintenance)", async () => {
Expand All @@ -53,7 +69,7 @@ describe("assertComposeBackendReady", () => {
mockGetUserAiCredentialPlaintext.mockResolvedValue(null);
await expect(
assertComposeBackendReady({
backend: "user_anthropic",
backend: "user_google",
graphId: "wiki-compose-research",
userId: "u1",
tier: "free",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Tests for fixed Wiki Compose model id resolution.
* 固定 Wiki Compose モデル id 解決のテスト。
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
resolveWikiComposeModelId,
WIKI_COMPOSE_MODEL_ID,
} from "../../../../agents/core/llm/wikiComposeModelId.js";

const mockDb = {
select: vi.fn(),
};

function chainLimit(rows: unknown[]) {
const chain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue(rows),
};
return chain;
}

beforeEach(() => {
vi.clearAllMocks();
});

describe("resolveWikiComposeModelId", () => {
it("returns the fixed id when the row is active and tier-accessible", async () => {
mockDb.select.mockReturnValueOnce(chainLimit([{ id: WIKI_COMPOSE_MODEL_ID }]));
const id = await resolveWikiComposeModelId("orchestrator", "free", mockDb as never);
expect(id).toBe(WIKI_COMPOSE_MODEL_ID);
});

it("returns the fixed id even when no DB row matches", async () => {
mockDb.select.mockReturnValueOnce(chainLimit([]));
const id = await resolveWikiComposeModelId("draft", "pro", mockDb as never);
expect(id).toBe("google:gemini-3.5-flash");
});

it("uses the same id for orchestrator and draft roles", async () => {
mockDb.select.mockReturnValue(chainLimit([{ id: WIKI_COMPOSE_MODEL_ID }]));
const orchestrator = await resolveWikiComposeModelId("orchestrator", "free", mockDb as never);
const draft = await resolveWikiComposeModelId("draft", "free", mockDb as never);
expect(orchestrator).toBe(draft);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { createZediChatModel } = vi.hoisted(() => ({ createZediChatModel: vi.fn() }));

vi.mock("../../../../../agents/core/llm/wikiComposeModelId.js", () => ({
WIKI_COMPOSE_MODEL_ID: "google:gemini-3.5-flash",
resolveWikiComposeModelId: vi.fn(async () => "google:gemini-3.5-flash"),
}));

vi.mock("../../../../../agents/core/llm/modelFactory.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../../agents/core/llm/modelFactory.js")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { createZediChatModel } = vi.hoisted(() => ({ createZediChatModel: vi.fn() }));

vi.mock("../../../../agents/core/llm/wikiComposeModelId.js", () => ({
WIKI_COMPOSE_MODEL_ID: "google:gemini-3.5-flash",
resolveWikiComposeModelId: vi.fn(async () => "google:gemini-3.5-flash"),
}));

vi.mock("../../../../agents/core/llm/modelFactory.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../agents/core/llm/modelFactory.js")
Expand Down
18 changes: 12 additions & 6 deletions server/api/src/agents/core/composeBackendValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
* Pre-flight BYOK checks for Wiki Compose session creation (#951).
* Wiki Compose セッション作成前の BYOK 事前チェック(#951)。
*
* Static env model ids are not validated here — provider matching is enforced at
* runtime via {@link resolveComposeModelId}. This function only verifies that
* the user has a stored credential when the graph will call an LLM.
* Wiki Compose uses a fixed Google model at runtime ({@link WIKI_COMPOSE_MODEL_ID}).
* Non-Google BYOK backends are rejected at session create to avoid runtime
* `BackendProviderMismatchError` in `createZediChatModel`.
*
* 静的 env モデル id との provider 照合は行わない。実行時の
* `resolveComposeModelId` が provider 整合を担保する。本関数は LLM を呼ぶ
* グラフで credential が存在するかだけを確認する。
* Wiki Compose は Google 固定モデルを使う。`user_anthropic` / `user_openai` など
* provider が合わない BYOK はセッション作成時に 400 で弾く。
*/
import { HTTPException } from "hono/http-exception";
import type { Database, UserTier } from "../../types/index.js";
import { getComposeModelIdsForGraph } from "./composeModelConfig.js";
import { isFixedWikiComposeModelGraph, WIKI_COMPOSE_MODEL_ID } from "./llm/wikiComposeModelId.js";
import {
backendToCredentialProvider,
isUserByokBackend,
Expand All @@ -38,6 +38,12 @@ export async function assertComposeBackendReady(input: {
// LLM を呼ばないグラフ(wiki-maintenance 等)は credential 不要。
if (modelIds.length === 0) return;

if (isFixedWikiComposeModelGraph(input.graphId) && input.backend !== "user_google") {
throw new HTTPException(400, {
message: `Wiki Compose requires zedi_managed or user_google backend (fixed model ${WIKI_COMPOSE_MODEL_ID})`,
});
}

const expectedProvider = backendToCredentialProvider(input.backend);
const key = await getUserAiCredentialPlaintext(input.userId, expectedProvider, input.db);
if (!key?.trim()) {
Expand Down
17 changes: 6 additions & 11 deletions server/api/src/agents/core/composeModelConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,7 @@ import { WIKI_MAINTENANCE_GRAPH_ID } from "../graphs/wikiMaintenance/index.js";
import { getOrchestratorModelId } from "../subgraphs/research/nodes/planQueries.js";
import { RESEARCH_GRAPH_ID } from "../subgraphs/research/index.js";
import { INGEST_PLANNER_GRAPH_ID } from "../graphs/ingest/index.js";

const DRAFT_MODEL_ENV = "WIKI_COMPOSE_DRAFT_MODEL_ID";
const DRAFT_MODEL_FALLBACK = "claude-3-5-sonnet";

function getDraftModelId(): string {
return process.env[DRAFT_MODEL_ENV]?.trim() || DRAFT_MODEL_FALLBACK;
}
import { WIKI_COMPOSE_MODEL_ID } from "./llm/wikiComposeModelId.js";

/**
* Model row ids (`ai_models.id`) that a compose graph run will call via `createZediChatModel`.
Expand All @@ -23,11 +17,12 @@ export function getComposeModelIdsForGraph(graphId: string): string[] {
// Lint-only graph — no `createZediChatModel` calls; BYOK must not require orchestrator keys.
if (graphId === WIKI_MAINTENANCE_GRAPH_ID) return [];
if (graphId === WIKI_COMPOSE_GRAPH_ID) {
const orchestrator = getOrchestratorModelId();
const draft = getDraftModelId();
return orchestrator === draft ? [orchestrator] : [orchestrator, draft];
return [WIKI_COMPOSE_MODEL_ID];
}
if (graphId === RESEARCH_GRAPH_ID) {
return [WIKI_COMPOSE_MODEL_ID];
}
if (graphId === RESEARCH_GRAPH_ID || graphId === INGEST_PLANNER_GRAPH_ID) {
if (graphId === INGEST_PLANNER_GRAPH_ID) {
return [getOrchestratorModelId()];
}
return [getOrchestratorModelId()];
Expand Down
6 changes: 3 additions & 3 deletions server/api/src/agents/core/llm/resolveComposeModelId.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Resolve `ai_models.id` for Wiki Compose nodes so BYOK backends use a matching provider.
* Resolve `ai_models.id` for non–Wiki Compose graphs (e.g. ingest planner).
* Wiki Compose uses {@link resolveWikiComposeModelId} instead.
*
* Wiki Compose の LLM ノード用 model id 解決。BYOK backend では provider が一致する
* active モデルを選び、`BackendProviderMismatchError` でセッション全体が落ちるのを防ぐ。
* Wiki Compose 以外(ingest planner 等)向けの model id 解決。
*/
import { and, asc, eq } from "drizzle-orm";
import { aiModels } from "../../../schema/index.js";
Expand Down
63 changes: 63 additions & 0 deletions server/api/src/agents/core/llm/wikiComposeModelId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Fixed Wiki Compose model id (temporary until per-role / per-backend selection returns).
* Wiki Compose 用の固定モデル id(将来ロール別選択に戻すまでの暫定)。
*/
import { and, eq } from "drizzle-orm";
import { aiModels } from "../../../schema/index.js";
import type { Database, UserTier } from "../../../types/index.js";
import { WIKI_COMPOSE_GRAPH_ID } from "../../graphs/wikiCompose/index.js";
import { RESEARCH_GRAPH_ID } from "../../subgraphs/research/index.js";
import type { ComposeModelRole } from "./resolveComposeModelId.js";

/**
* `ai_models.id` used by every Wiki Compose LLM node and the `web_search` tool.
* すべての Wiki Compose LLM ノードと `web_search` ツールが使う `ai_models.id`。
*/
export const WIKI_COMPOSE_MODEL_ID = "google:gemini-3.5-flash" as const;

/** Graph ids that pin LLM calls to {@link WIKI_COMPOSE_MODEL_ID}. */
export function isFixedWikiComposeModelGraph(graphId: string): boolean {
return graphId === WIKI_COMPOSE_GRAPH_ID || graphId === RESEARCH_GRAPH_ID;
}
Comment on lines +18 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add Japanese text to this exported JSDoc block for guideline compliance.

Line 18 currently has English-only documentation while this file otherwise follows bilingual EN/JA comments.

Proposed fix
-/** Graph ids that pin LLM calls to {`@link` WIKI_COMPOSE_MODEL_ID}. */
+/**
+ * Graph ids that pin LLM calls to {`@link` WIKI_COMPOSE_MODEL_ID}.
+ * LLM 呼び出しを {`@link` WIKI_COMPOSE_MODEL_ID} に固定する graph id。
+ */
 export function isFixedWikiComposeModelGraph(graphId: string): boolean {
   return graphId === WIKI_COMPOSE_GRAPH_ID || graphId === RESEARCH_GRAPH_ID;
 }

As per coding guidelines "Include both Japanese and English comments/documentation in code and documentation files to maintain project tone consistency".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/agents/core/llm/wikiComposeModelId.ts` around lines 18 - 21,
The JSDoc for isFixedWikiComposeModelGraph is English-only; update the exported
JSDoc comment above the function to include a concise Japanese translation
alongside the existing English text (matching the file's bilingual style). Keep
the same description and reference to WIKI_COMPOSE_MODEL_ID, and ensure the
Japanese sentence clearly states the same purpose (that the function checks
graph ids pinned to WIKI_COMPOSE_MODEL_ID); leave the function and constants
(WIKI_COMPOSE_GRAPH_ID, RESEARCH_GRAPH_ID) unchanged.


function tierFilter(tier: UserTier) {
if (tier === "pro") return undefined;
return eq(aiModels.tierRequired, "free");
}

/**
* Returns the fixed model row id when active and tier-accessible; otherwise `null`.
* `null` のとき呼び出し側はフォールバック(web_search の cheapest 探索など)へ進める。
*/
export async function resolveActiveWikiComposeModelId(
db: Database,
tier: UserTier,
): Promise<string | null> {
const tierClause = tierFilter(tier);
const [row] = await db
.select({ id: aiModels.id })
.from(aiModels)
.where(
and(
eq(aiModels.id, WIKI_COMPOSE_MODEL_ID),
eq(aiModels.isActive, true),
...(tierClause ? [tierClause] : []),
),
)
.limit(1);
return row?.id ?? null;
}

/**
* Resolve the model row id for Wiki Compose orchestrator / draft / research nodes.
* `role` is accepted for API stability; all roles map to {@link WIKI_COMPOSE_MODEL_ID} for now.
*
* Wiki Compose の model id 解決。現時点では role に関わらず gemini-3.5-flash 固定。
*/
export async function resolveWikiComposeModelId(
_role: ComposeModelRole,
_tier: UserTier,
db: Database,
): Promise<string> {
return (await resolveActiveWikiComposeModelId(db, _tier)) ?? WIKI_COMPOSE_MODEL_ID;
}
13 changes: 9 additions & 4 deletions server/api/src/agents/core/tools/resolveWebSearchModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
* `useGoogleSearch` for Google) を呼ぶため、Anthropic-only な選択では成立しない。
* 本ヘルパは次の優先順で model を選ぶ:
*
* 1. `process.env.WIKI_COMPOSE_WEB_SEARCH_MODEL_ID` (explicit override; `ai_models.id`)
* 1. {@link WIKI_COMPOSE_MODEL_ID} when active and tier-accessible
* 2. `process.env.WIKI_COMPOSE_WEB_SEARCH_MODEL_ID` (explicit override; `ai_models.id`)
* — 必ず active かつ tier 通過することを DB 側で確認する(coderabbit review #956:
* 不正な override で `createZediChatModel` が失敗してエラー envelope になる
* のを防ぐ)。
* 2. `ai_models` の active な OpenAI モデルで最安 (`input_cost_units` ASC, `output_cost_units` ASC)
* 3. `ai_models` の active な Google モデルで最安
* 4. 何も無ければ `null` を返す(ツール側は empty result + note を返す)。
* 3. `ai_models` の active な OpenAI モデルで最安 (`input_cost_units` ASC, `output_cost_units` ASC)
* 4. `ai_models` の active な Google モデルで最安
* 5. 何も無ければ `null` を返す(ツール側は empty result + note を返す)。
*
* Returns the `ai_models.id` so `createZediChatModel({ modelId })` can validate
* tier access and resolve the API key uniformly. Centralising the choice in one
Expand All @@ -25,6 +26,7 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { aiModels } from "../../../schema/index.js";
import type { Database, UserTier } from "../../../types/index.js";
import { resolveActiveWikiComposeModelId } from "../llm/wikiComposeModelId.js";

const ENV_OVERRIDE = "WIKI_COMPOSE_WEB_SEARCH_MODEL_ID";

Expand All @@ -48,6 +50,9 @@ export async function resolveWebSearchModelId(
db: Database,
tier: UserTier,
): Promise<string | null> {
const fixedId = await resolveActiveWikiComposeModelId(db, tier);
if (fixedId) return fixedId;

const override = process.env[ENV_OVERRIDE]?.trim();
if (override) {
// Validate the override before returning: it must be active and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { randomUUID } from "node:crypto";
import { z } from "zod";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js";
import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js";
import { loadPageSnapshot } from "./shared/loadPageSnapshot.js";
import { dispatchComposePhase } from "./shared/dispatch.js";
Expand Down Expand Up @@ -117,7 +117,7 @@ export async function briefDialogue(
// セッション開始時に 1 度だけ読み、以後は state を参照する。
const snapshot = state.pageSnapshot ?? (await loadPageSnapshot(ctx.db, ctx.pageId));

const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db);
const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db);
const model = await createZediChatModel({
modelId,
userId: ctx.userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js";
import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js";
import { dispatchComposePhase, dispatchComposeSection } from "./shared/dispatch.js";
import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js";
Expand Down Expand Up @@ -127,7 +127,7 @@ export async function draftSections(
return { draftedSections: [], phase: "draft:completed" };
}

const modelId = await resolveComposeModelId("draft", ctx.backend, ctx.tier, ctx.db);
const modelId = await resolveWikiComposeModelId("draft", ctx.tier, ctx.db);
const model = await createZediChatModel({
modelId,
userId: ctx.userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
structureDialogueFallbackOutline,
} from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js";
import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js";
import { dispatchComposePhase } from "./shared/dispatch.js";
import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js";
Expand Down Expand Up @@ -83,7 +83,7 @@ export async function structureDialogue(

await dispatchComposePhase({ phase: "structure", status: "entered" }, config);

const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db);
const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db);
const model = await createZediChatModel({
modelId,
userId: ctx.userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { z } from "zod";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js";
import { getGraphContext } from "./shared/getGraphContext.js";
import { dispatchResearchEvaluation } from "./shared/dispatchSseCustom.js";
import type { ResearchLoopStateType, ResearchLoopStateUpdate } from "../state.js";
Expand Down Expand Up @@ -72,7 +72,7 @@ export async function evaluateSufficiency(
): Promise<ResearchLoopStateUpdate> {
const ctx = getGraphContext(config);

const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db);
const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db);
const model = await createZediChatModel({
modelId,
userId: ctx.userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import { randomUUID } from "node:crypto";
import { z } from "zod";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js";
import { getGraphContext } from "./shared/getGraphContext.js";
import { dispatchResearchIteration } from "./shared/dispatchSseCustom.js";
import type { ResearchLoopStateType, ResearchLoopStateUpdate } from "../state.js";
import type { PlannedQuery, Source } from "../types.js";

/**
* @deprecated Use {@link resolveComposeModelId} with graph context. Kept for tests importing the symbol.
* @deprecated Use {@link resolveWikiComposeModelId} for Wiki Compose graphs; kept for ingest planner BYOK preflight ({@link getComposeModelIdsForGraph}).
* Wiki Compose では {@link resolveWikiComposeModelId} を使う。ingest BYOK 事前検証用。
*/
export function getOrchestratorModelId(): string {
return process.env.WIKI_COMPOSE_ORCHESTRATOR_MODEL_ID?.trim() || "claude-3-5-haiku";
Expand Down Expand Up @@ -100,7 +101,7 @@ export async function planQueries(
// maxIterations は既存 state を優先しつつ 1..5 にクランプ。
const maxIterations = clampMaxIterations(state.maxIterations ?? 3);

const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db);
const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db);
const model = await createZediChatModel({
modelId,
userId: ctx.userId,
Expand Down
Loading
Loading