This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@aao/answer-worker-contract",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest';
|
||||
import {
|
||||
diagnoseAnswerWorker,
|
||||
isStaticallySafeTokenizerEndpoint,
|
||||
} from './index.js';
|
||||
|
||||
describe('answer-worker shared diagnostics', () => {
|
||||
it.each([
|
||||
['https://tokenizer.example/tokenize', 'http://worker.example/v1'],
|
||||
['http://localhost/tokenize', 'http://worker.example/v1'],
|
||||
['http://127.0.0.1/tokenize', 'http://worker.example/v1'],
|
||||
['http://[::1]/tokenize', 'http://worker.example/v1'],
|
||||
['http://LLM-LAN:9090/tokenize', 'https://llm-lan:8080/v1'],
|
||||
])('accepts a statically safe tokenizer endpoint: %s', (endpoint, workerEndpoint) => {
|
||||
expect(isStaticallySafeTokenizerEndpoint(endpoint, workerEndpoint)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not a URL', 'http://worker.example/v1'],
|
||||
['http://10.0.0.7/tokenize', 'http://10.0.0.8/v1'],
|
||||
['http://10.0.0.7/tokenize', 'not a worker URL'],
|
||||
['ftp://tokenizer.example/tokenize', 'http://tokenizer.example/v1'],
|
||||
])('rejects an unsafe tokenizer endpoint: %s', (endpoint, workerEndpoint) => {
|
||||
expect(isStaticallySafeTokenizerEndpoint(endpoint, workerEndpoint)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a title-role dictionary worker eligible when capabilities are omitted', () => {
|
||||
const diagnosis = diagnoseAnswerWorker({
|
||||
provider: {
|
||||
workers: [{ id: 'legacy-title', endpoint: 'http://worker', model: 'm', roles: ['title'] }],
|
||||
},
|
||||
}, 'dictionary');
|
||||
|
||||
expect(diagnosis).toMatchObject({ state: 'fallback', workerId: 'legacy-title', fallback: true });
|
||||
});
|
||||
|
||||
it('treats only explicit streaming false as missing', () => {
|
||||
const diagnosis = diagnoseAnswerWorker({
|
||||
provider: {
|
||||
workers: [{
|
||||
id: 'disabled-streaming',
|
||||
endpoint: 'http://worker',
|
||||
model: 'm',
|
||||
roles: ['dictionary'],
|
||||
capabilities: { streaming: false },
|
||||
}],
|
||||
},
|
||||
}, 'dictionary');
|
||||
|
||||
expect(diagnosis).toMatchObject({ state: 'unavailable', missingFields: ['streaming'] });
|
||||
});
|
||||
|
||||
it('still requires explicit tool calling support for document questions', () => {
|
||||
const diagnosis = diagnoseAnswerWorker({
|
||||
provider: {
|
||||
workers: [{
|
||||
id: 'document',
|
||||
endpoint: 'http://worker',
|
||||
model: 'm',
|
||||
roles: ['document_question'],
|
||||
contextWindow: 16_384,
|
||||
tokenizerEndpoint: 'https://tokenizer.example/tokenize',
|
||||
}],
|
||||
},
|
||||
}, 'document_question');
|
||||
|
||||
expect(diagnosis).toMatchObject({
|
||||
state: 'unavailable',
|
||||
missingFields: ['toolCalling'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses default roles and reports an empty ID from the same normalization', () => {
|
||||
const diagnosis = diagnoseAnswerWorker({
|
||||
provider: {
|
||||
workers: [{ id: '', endpoint: 'http://worker', model: 'm', capabilities: { streaming: true } }],
|
||||
},
|
||||
}, 'dictionary');
|
||||
|
||||
expect(diagnosis).toMatchObject({ state: 'unavailable', missingFields: ['id'] });
|
||||
expect(diagnosis.workers[0]).toMatchObject({ roles: ['auto', 'fast', 'quality'], missingFields: ['id'] });
|
||||
});
|
||||
it('becomes ready when a fallback worker receives every required field', () => {
|
||||
const config = { llm: { workers: [{ id: 'quality', roles: ['quality'] }] } };
|
||||
expect(diagnoseAnswerWorker(config, 'document_question')).toMatchObject({
|
||||
state: 'unavailable',
|
||||
missingFields: expect.arrayContaining(['endpoint', 'model', 'toolCalling', 'contextWindow', 'tokenizerEndpoint']),
|
||||
});
|
||||
|
||||
config.llm.workers[0] = {
|
||||
id: 'quality', endpoint: 'http://worker', model: 'm', roles: ['quality'],
|
||||
capabilities: { streaming: true, toolCalling: true }, contextWindow: 16_384, tokenizerEndpoint: 'https://tokenizer.example/tokenize',
|
||||
};
|
||||
expect(diagnoseAnswerWorker(config, 'document_question')).toMatchObject({ state: 'fallback', workerId: 'quality' });
|
||||
});
|
||||
|
||||
it('uses the state discriminator for selected and unavailable results', () => {
|
||||
const selected = diagnoseAnswerWorker({ provider: { workers: [{ id: 'fast', endpoint: 'http://worker', model: 'm', roles: ['fast'], capabilities: { streaming: true } }] } }, 'dictionary');
|
||||
if (selected.state !== 'unavailable') {
|
||||
expectTypeOf(selected.workerId).toEqualTypeOf<string>();
|
||||
expectTypeOf(selected.code).toEqualTypeOf<never | undefined>();
|
||||
}
|
||||
|
||||
const unavailable = diagnoseAnswerWorker({}, 'dictionary');
|
||||
if (unavailable.state === 'unavailable') {
|
||||
expectTypeOf(unavailable.code).toEqualTypeOf<'dictionary_worker_not_configured' | 'document_chat_worker_not_configured' | 'document_chat_worker_incompatible' | 'document_chat_tokenizer_not_configured'>();
|
||||
expect(unavailable.code).toBe('dictionary_worker_not_configured');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("worker id length contract", () => {
|
||||
it.each([[127, true], [128, true], [129, false]] as const)("validates %i chars", (length, valid) => {
|
||||
const diagnosis = diagnoseAnswerWorker({ provider: { workers: [{ id: "x".repeat(length), endpoint: "http://w", model: "m", roles: ["dictionary"], capabilities: { streaming: true } }] } }, "dictionary");
|
||||
expect(diagnosis.state === "dedicated").toBe(valid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image capability declarations', () => {
|
||||
it('selects a worker declaring capabilities.image without a legacy VLM flag', () => {
|
||||
const diagnosis = diagnoseAnswerWorker({ llm: { workers: [{ id: 'image', endpoint: 'http://image.test/v1', model: 'm', capabilities: { image: true, audio: true, video: false } }] } }, 'vision');
|
||||
expect(diagnosis.workerId).toBe('image');
|
||||
});
|
||||
it('treats an explicit image=false as authoritative over a legacy true', () => {
|
||||
const diagnosis = diagnoseAnswerWorker({ llm: { workers: [{ id: 'image', endpoint: 'http://image.test/v1', model: 'm', vlm: true, capabilities: { image: false, audio: true } }] } }, 'vision');
|
||||
expect(diagnosis.state).toBe('unavailable');
|
||||
expect(diagnosis.missingFields).toContain('image');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Browser-safe worker selection and diagnostics shared by the server and Settings.
|
||||
* Keep this module free of Node imports so the UI can evaluate its unsaved draft
|
||||
* with exactly the same eligibility rules as the runtime selector.
|
||||
*/
|
||||
export const MAX_ANSWER_WORKER_ID_LENGTH = 128;
|
||||
export const isValidAnswerWorkerId = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0 && value.length <= MAX_ANSWER_WORKER_ID_LENGTH;
|
||||
|
||||
export const MEDIA_KINDS = ['image', 'audio', 'video'] as const;
|
||||
export type MediaKind = typeof MEDIA_KINDS[number];
|
||||
export type MediaCapabilities = Partial<Record<MediaKind, boolean>>;
|
||||
export interface WorkerCapabilities extends MediaCapabilities {
|
||||
streaming?: boolean;
|
||||
toolCalling?: boolean;
|
||||
}
|
||||
|
||||
/** Legacy drafts remain readable; an explicit new false always wins. */
|
||||
export function acceptsImages(worker: { capabilities?: WorkerCapabilities; vlm?: boolean }): boolean {
|
||||
return (worker.capabilities?.image ?? worker.vlm) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `vision` は他の 2 つと候補の選び方が違う。役割ではなく `capabilities.image` フラグで選ぶ
|
||||
* (issue #1056)。役割を足すと「画像を扱える」を 2 か所で宣言することになり、
|
||||
* チェックを入れたのに役割も付けないと効かない、という設定になる。
|
||||
*/
|
||||
export type AnswerWorkerPurpose = 'dictionary' | 'document_question' | 'vision';
|
||||
|
||||
export type AnswerWorkerMissingField =
|
||||
| 'id'
|
||||
| 'worker'
|
||||
| 'endpoint'
|
||||
| 'model'
|
||||
| 'streaming'
|
||||
| 'toolCalling'
|
||||
| 'contextWindow'
|
||||
| 'tokenizerEndpoint'
|
||||
| 'outputTokens'
|
||||
| 'maxConcurrentPerUser'
|
||||
| 'maxConcurrentTotal'
|
||||
| 'vlm'
|
||||
| 'image';
|
||||
|
||||
export type AnswerWorkerFailureCode =
|
||||
| 'dictionary_worker_unavailable'
|
||||
| 'document_chat_worker_unavailable'
|
||||
| 'vision_worker_unavailable'
|
||||
| 'dictionary_worker_not_configured'
|
||||
| 'document_chat_worker_not_configured'
|
||||
| 'document_chat_worker_incompatible'
|
||||
| 'document_chat_tokenizer_not_configured'
|
||||
| 'vision_worker_not_configured';
|
||||
|
||||
export type AnswerWorker = {
|
||||
id?: string;
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
enabled?: boolean;
|
||||
roles?: string[];
|
||||
/** @deprecated Legacy draft input; use capabilities.image. */
|
||||
vlm?: boolean;
|
||||
capabilities?: WorkerCapabilities;
|
||||
contextWindow?: number;
|
||||
tokenizerEndpoint?: string;
|
||||
};
|
||||
|
||||
export type AnswerWorkerConfig = {
|
||||
provider?: { model?: string; workers?: AnswerWorker[] };
|
||||
/** v2 Settings drafts edit llm.workers before the config is normalized. */
|
||||
llm?: { workers?: AnswerWorker[] };
|
||||
documentChat?: {
|
||||
outputTokens?: number;
|
||||
maxConcurrentPerUser?: number;
|
||||
maxConcurrentTotal?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AnswerWorkerDiagnostic = {
|
||||
workerIndex: number;
|
||||
workerId: string;
|
||||
roles: string[];
|
||||
missingFields: AnswerWorkerMissingField[];
|
||||
};
|
||||
|
||||
type AnswerWorkerDiagnosisBase = {
|
||||
purpose: AnswerWorkerPurpose;
|
||||
workers: AnswerWorkerDiagnostic[];
|
||||
};
|
||||
|
||||
/**
|
||||
* An eligible worker, ranked by role priority. `rank` 0 is the purpose's own
|
||||
* role (a dedicated worker); higher ranks are fallbacks. Callers that know how
|
||||
* busy each worker is pick from this list; callers that do not (the Settings
|
||||
* preview runs in the browser) use `selectedWorkerIndex`, which is simply the
|
||||
* first entry.
|
||||
*/
|
||||
export type AnswerWorkerCandidate = {
|
||||
workerId: string;
|
||||
workerIndex: number;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
export type AvailableAnswerWorkerDiagnosis = AnswerWorkerDiagnosisBase & {
|
||||
state: 'dedicated' | 'fallback';
|
||||
workerId: string;
|
||||
selectedWorkerIndex: number;
|
||||
fallback: boolean;
|
||||
candidates: AnswerWorkerCandidate[];
|
||||
code?: never;
|
||||
missingFields: [];
|
||||
};
|
||||
|
||||
export type UnavailableAnswerWorkerDiagnosis = AnswerWorkerDiagnosisBase & {
|
||||
state: 'unavailable';
|
||||
candidates?: never;
|
||||
workerId?: never;
|
||||
selectedWorkerIndex?: never;
|
||||
fallback: false;
|
||||
code: AnswerWorkerFailureCode;
|
||||
missingFields: AnswerWorkerMissingField[];
|
||||
};
|
||||
|
||||
export type AnswerWorkerDiagnosis = AvailableAnswerWorkerDiagnosis | UnavailableAnswerWorkerDiagnosis;
|
||||
|
||||
/**
|
||||
* API 応答へそのまま載せられる公開形。workerIndex (内部配列位置) を落とし、
|
||||
* endpoint / model などの値は最初から含めない (workerId / roles / 不足フィールド名のみ)。
|
||||
*/
|
||||
export type PublicAnswerWorkerDiagnostic = {
|
||||
workerId: string;
|
||||
roles: string[];
|
||||
missingFields: AnswerWorkerMissingField[];
|
||||
};
|
||||
export type PublicAnswerWorkerDiagnosis = {
|
||||
purpose: AnswerWorkerPurpose;
|
||||
workers: PublicAnswerWorkerDiagnostic[];
|
||||
};
|
||||
export const toPublicAnswerWorkerDiagnosis = (
|
||||
diagnosis: AnswerWorkerDiagnosis,
|
||||
): PublicAnswerWorkerDiagnosis => ({
|
||||
purpose: diagnosis.purpose,
|
||||
workers: diagnosis.workers.map(({ workerId, roles, missingFields }) => ({ workerId, roles, missingFields })),
|
||||
});
|
||||
|
||||
const ROLE_PRIORITY = {
|
||||
dictionary: ['dictionary', 'fast', 'title'],
|
||||
document_question: ['document_question', 'quality', 'auto', 'fast'],
|
||||
// vision は役割で絞らない。`vlm` を立てたワーカーが全部同格の候補になる。
|
||||
vision: [] as readonly string[],
|
||||
} as const;
|
||||
|
||||
const isPositiveInteger = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0;
|
||||
|
||||
const DEFAULT_WORKER_ROLES = ['auto', 'fast', 'quality'];
|
||||
const LOOPBACK_TOKENIZER_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
export const isLoopbackTokenizerHost = (hostname: string): boolean =>
|
||||
LOOPBACK_TOKENIZER_HOSTS.has(hostname);
|
||||
|
||||
/**
|
||||
* HTTPS is always allowed. HTTP is allowed for loopback or the worker's
|
||||
* hostname regardless of either endpoint's port or the worker's protocol.
|
||||
*/
|
||||
export const isStaticallySafeTokenizerEndpoint = (
|
||||
value: string | undefined,
|
||||
workerEndpoint: string | undefined,
|
||||
): boolean => {
|
||||
try {
|
||||
const url = new URL(value ?? '');
|
||||
if (url.protocol === 'https:') return true;
|
||||
if (url.protocol !== 'http:') return false;
|
||||
if (isLoopbackTokenizerHost(url.hostname)) return true;
|
||||
const workerUrl = new URL(workerEndpoint ?? '');
|
||||
return url.hostname.toLowerCase() === workerUrl.hostname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const hasText = (value: string | undefined): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
/**
|
||||
* Returns the selected worker or the exact fields that made every routed
|
||||
* candidate unavailable. `llm.workers` deliberately wins over `provider`
|
||||
* so an unsaved Settings draft is diagnosed rather than its saved counterpart.
|
||||
*/
|
||||
export const diagnoseAnswerWorker = (
|
||||
config: AnswerWorkerConfig,
|
||||
purpose: AnswerWorkerPurpose,
|
||||
): AnswerWorkerDiagnosis => {
|
||||
const configuredWorkers = config.llm?.workers ?? config.provider?.workers ?? [];
|
||||
const enabledWorkers = configuredWorkers
|
||||
.map((worker, workerIndex) => ({ worker, workerIndex }))
|
||||
.filter(({ worker }) => worker.enabled !== false);
|
||||
const duplicateIds = new Set(
|
||||
enabledWorkers
|
||||
.map(({ worker }) => worker.id?.trim())
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id, index, ids) => ids.indexOf(id) !== index),
|
||||
);
|
||||
const workers = enabledWorkers
|
||||
.map(({ worker, workerIndex }) => ({
|
||||
...worker,
|
||||
workerIndex,
|
||||
roles: Array.isArray(worker.roles) && worker.roles.length > 0 ? worker.roles : DEFAULT_WORKER_ROLES,
|
||||
}));
|
||||
const documentChat = config.documentChat ?? {};
|
||||
const defaultModel = config.provider?.model;
|
||||
|
||||
const diagnostics: AnswerWorkerDiagnostic[] = workers.map(worker => {
|
||||
const missingFields: AnswerWorkerMissingField[] = [];
|
||||
if (!isValidAnswerWorkerId(worker.id) || duplicateIds.has(worker.id.trim())) missingFields.push('id');
|
||||
if (!hasText(worker.endpoint)) missingFields.push('endpoint');
|
||||
if (!hasText(worker.model ?? defaultModel)) missingFields.push('model');
|
||||
// Legacy workers proved streaming without metadata, so only explicit false rejects them.
|
||||
// Tool calling has no such compatibility evidence and still requires explicit true below.
|
||||
// vision の呼び出しは 1 回きりの非ストリーミング fetch なので、streaming の
|
||||
// 申告は関係ない。ここで見ると「ストリーミング不可」と書いただけのワーカーを
|
||||
// 画像の宛先から外してしまう。
|
||||
if (purpose !== 'vision' && worker.capabilities?.streaming === false) missingFields.push('streaming');
|
||||
|
||||
// 画像を受け取れると申告していないワーカーは宛先にしない。実行時ではなく
|
||||
// 設定画面で気づけるよう、不足項目として返す。
|
||||
if (purpose === 'vision' && !acceptsImages(worker)) missingFields.push('image');
|
||||
|
||||
if (purpose === 'document_question') {
|
||||
if (worker.capabilities?.toolCalling !== true) missingFields.push('toolCalling');
|
||||
if ((worker.contextWindow ?? 0) < 8_192) missingFields.push('contextWindow');
|
||||
if (!isStaticallySafeTokenizerEndpoint(worker.tokenizerEndpoint, worker.endpoint)) missingFields.push('tokenizerEndpoint');
|
||||
|
||||
const outputTokens = documentChat.outputTokens ?? 2_048;
|
||||
const maxConcurrentPerUser = documentChat.maxConcurrentPerUser ?? 1;
|
||||
const maxConcurrentTotal = documentChat.maxConcurrentTotal ?? 4;
|
||||
if (!isPositiveInteger(outputTokens) || outputTokens < 2_048 || outputTokens >= (worker.contextWindow ?? 0)) {
|
||||
missingFields.push('outputTokens');
|
||||
}
|
||||
if (!isPositiveInteger(maxConcurrentPerUser)) missingFields.push('maxConcurrentPerUser');
|
||||
if (!isPositiveInteger(maxConcurrentTotal) || maxConcurrentPerUser > maxConcurrentTotal) {
|
||||
missingFields.push('maxConcurrentTotal');
|
||||
}
|
||||
}
|
||||
|
||||
return { workerIndex: worker.workerIndex, workerId: worker.id ?? '', roles: worker.roles, missingFields };
|
||||
});
|
||||
|
||||
// Collect every eligible worker in role-priority order, not just the first.
|
||||
// Routing by load needs the whole list; a worker is listed once, at its best
|
||||
// rank.
|
||||
const candidates: AnswerWorkerCandidate[] = [];
|
||||
if (purpose === 'vision') {
|
||||
// 役割を見ないので順位も付かない。全員 rank 0 で並べ、どれを使うかは
|
||||
// 呼び出し側(空き枠の多い台を選ぶ)に委ねる。
|
||||
for (const candidate of diagnostics) {
|
||||
if (candidate.missingFields.length > 0) continue;
|
||||
candidates.push({ workerId: candidate.workerId, workerIndex: candidate.workerIndex, rank: 0 });
|
||||
}
|
||||
} else {
|
||||
const claimed = new Set<number>();
|
||||
for (const [roleIndex, role] of ROLE_PRIORITY[purpose].entries()) {
|
||||
for (const candidate of diagnostics) {
|
||||
if (claimed.has(candidate.workerIndex)) continue;
|
||||
if (!candidate.roles.includes(role) || candidate.missingFields.length > 0) continue;
|
||||
claimed.add(candidate.workerIndex);
|
||||
candidates.push({ workerId: candidate.workerId, workerIndex: candidate.workerIndex, rank: roleIndex });
|
||||
}
|
||||
}
|
||||
}
|
||||
const selected = candidates[0];
|
||||
if (selected) {
|
||||
return {
|
||||
purpose,
|
||||
state: selected.rank === 0 ? 'dedicated' : 'fallback',
|
||||
workerId: selected.workerId,
|
||||
selectedWorkerIndex: selected.workerIndex,
|
||||
fallback: selected.rank !== 0,
|
||||
candidates,
|
||||
missingFields: [],
|
||||
workers: diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// vision は役割で絞らないので、有効なワーカーは全員「宛先になり得た候補」。
|
||||
// 不足項目(`vlm` 無し・endpoint 空 など)をそのまま設定画面へ返す。
|
||||
const routedCandidates = purpose === 'vision'
|
||||
? diagnostics
|
||||
: diagnostics.filter(candidate =>
|
||||
ROLE_PRIORITY[purpose].some(role => candidate.roles.includes(role)),
|
||||
);
|
||||
const missingFields = routedCandidates[0]?.missingFields ?? ['worker'];
|
||||
const code: AnswerWorkerFailureCode = purpose === 'vision'
|
||||
? 'vision_worker_not_configured'
|
||||
: purpose === 'dictionary'
|
||||
? 'dictionary_worker_not_configured'
|
||||
: routedCandidates.length === 0
|
||||
? 'document_chat_worker_not_configured'
|
||||
: routedCandidates.some(candidate =>
|
||||
candidate.missingFields.length > 0
|
||||
&& candidate.missingFields.every(field => field === 'tokenizerEndpoint'),
|
||||
)
|
||||
? 'document_chat_tokenizer_not_configured'
|
||||
: 'document_chat_worker_incompatible';
|
||||
|
||||
return {
|
||||
purpose,
|
||||
state: 'unavailable',
|
||||
fallback: false,
|
||||
code,
|
||||
missingFields,
|
||||
workers: diagnostics,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user