sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
@@ -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"]
}