feat(users): add 'copy_reasoning_to_reasoning_content' option for user creation and updates
This commit is contained in:
parent
227e5b12da
commit
0f64a4cd85
20 changed files with 327 additions and 25 deletions
|
|
@ -99,7 +99,7 @@ export const api = {
|
|||
users: {
|
||||
getAll: (): Promise<User[]> => fetchJson<User[]>(`${API_BASE}/admin/users`),
|
||||
getById: (id: number): Promise<User> => fetchJson<User>(`${API_BASE}/admin/users/${id}`),
|
||||
create: (data: { name: string; email?: string; api_key?: string; detail_logging?: boolean }): Promise<User> =>
|
||||
create: (data: { name: string; email?: string; api_key?: string; detail_logging?: boolean; copy_reasoning_to_reasoning_content?: boolean }): Promise<User> =>
|
||||
fetchJson<User>(`${API_BASE}/admin/users`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<User>): Promise<User> =>
|
||||
fetchJson<User>(`${API_BASE}/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface UserFormState {
|
|||
api_key: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
copy_reasoning_to_reasoning_content: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = (): UserFormState => ({
|
||||
|
|
@ -46,6 +47,7 @@ const emptyForm = (): UserFormState => ({
|
|||
api_key: '',
|
||||
is_active: true,
|
||||
detail_logging: false,
|
||||
copy_reasoning_to_reasoning_content: false,
|
||||
});
|
||||
|
||||
const maskApiKey = (apiKey: string) => `${apiKey.slice(0, 5)}...`;
|
||||
|
|
@ -132,6 +134,7 @@ export const Users: Component = () => {
|
|||
api_key: user.api_key,
|
||||
is_active: user.is_active,
|
||||
detail_logging: user.detail_logging,
|
||||
copy_reasoning_to_reasoning_content: user.copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
|
@ -154,6 +157,7 @@ export const Users: Component = () => {
|
|||
api_key: current.api_key.trim() || undefined,
|
||||
is_active: current.is_active,
|
||||
detail_logging: current.detail_logging,
|
||||
copy_reasoning_to_reasoning_content: current.copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
setNotice({ tone: 'success', message: 'User updated.' });
|
||||
} else {
|
||||
|
|
@ -162,6 +166,7 @@ export const Users: Component = () => {
|
|||
email: current.email.trim() || undefined,
|
||||
api_key: current.api_key.trim() || undefined,
|
||||
detail_logging: current.detail_logging,
|
||||
copy_reasoning_to_reasoning_content: current.copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
setNotice({ tone: 'success', message: 'User created.' });
|
||||
}
|
||||
|
|
@ -365,6 +370,11 @@ export const Users: Component = () => {
|
|||
header: 'Detail Log',
|
||||
cell: (user) => <StatusBadge tone={user.detail_logging ? 'warning' : 'neutral'}>{user.detail_logging ? 'On' : 'Off'}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'reasoning_compat',
|
||||
header: 'Reasoning Compat',
|
||||
cell: (user) => <StatusBadge tone={user.copy_reasoning_to_reasoning_content ? 'success' : 'neutral'}>{user.copy_reasoning_to_reasoning_content ? 'On' : 'Off'}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
|
|
@ -529,6 +539,12 @@ export const Users: Component = () => {
|
|||
checked={form().detail_logging}
|
||||
onChange={(checked) => setForm((current) => ({ ...current, detail_logging: checked }))}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Copy reasoning to reasoning_content"
|
||||
description="Enable for clients that only display thinking from reasoning_content."
|
||||
checked={form().copy_reasoning_to_reasoning_content}
|
||||
onChange={(checked) => setForm((current) => ({ ...current, copy_reasoning_to_reasoning_content: checked }))}
|
||||
/>
|
||||
</form>
|
||||
</FormDialog>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export type User = {
|
|||
email?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
copy_reasoning_to_reasoning_content: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -288,8 +288,12 @@ function parseStreamResponse(value: unknown): ParsedStreamResponse | null {
|
|||
if (isRecord(delta)) {
|
||||
if (typeof delta.role === 'string') choice.role = delta.role;
|
||||
if (typeof delta.content === 'string') choice.content.push(delta.content);
|
||||
if (typeof delta.reasoning === 'string') choice.reasoning.push(delta.reasoning);
|
||||
if (typeof delta.reasoning_content === 'string') choice.reasoning.push(delta.reasoning_content);
|
||||
const reasoning = typeof delta.reasoning_content === 'string'
|
||||
? delta.reasoning_content
|
||||
: typeof delta.reasoning === 'string'
|
||||
? delta.reasoning
|
||||
: undefined;
|
||||
if (reasoning) choice.reasoning.push(reasoning);
|
||||
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const rawToolCall of delta.tool_calls) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
email TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
detail_logging INTEGER NOT NULL DEFAULT 0,
|
||||
copy_reasoning_to_reasoning_content INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
|
||||
추가 동작:
|
||||
- `/v1/chat/completions` 는 요청 모델명을 먼저 전역 rewrite 체인으로 해석한 뒤, 최종 모델을 서빙하는 허용 가능한 활성 백엔드만 후보로 사용한다
|
||||
- 사용자 옵션 `copy_reasoning_to_reasoning_content` 가 켜져 있으면 chat completion 응답의 `reasoning` 필드를 `reasoning_content` 로 추가 복제한다. streaming/non-stream 모두 적용되며 기존 `reasoning_content` 는 덮어쓰지 않는다
|
||||
- `force=true` rewrite 는 항상 적용되고 target 모델의 다음 규칙까지 계속 평가한다
|
||||
- `force=false` rewrite 는 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용되고 target 모델의 다음 규칙까지 계속 평가한다
|
||||
- `/v1/models` 는 native backend 모델뿐 아니라 현재 사용자 권한에서 최종 후보가 있는 rewrite source alias도 함께 반환한다
|
||||
|
|
@ -54,9 +55,9 @@
|
|||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/admin/users` | 전체 사용자 목록 |
|
||||
| POST | `/admin/users` | 사용자 생성 (`api_key` 생략 시 자동 발급, 지정 시 수동 등록) |
|
||||
| POST | `/admin/users` | 사용자 생성 (`api_key` 생략 시 자동 발급, 지정 시 수동 등록, copy_reasoning_to_reasoning_content 선택 가능) |
|
||||
| GET | `/admin/users/:id` | 사용자 조회 |
|
||||
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, api_key, is_active, detail_logging) |
|
||||
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, api_key, is_active, detail_logging, copy_reasoning_to_reasoning_content) |
|
||||
| DELETE | `/admin/users/:id` | 사용자 삭제 |
|
||||
| POST | `/admin/users/:id/regenerate-api-key` | API 키 재발급 |
|
||||
|
||||
|
|
|
|||
|
|
@ -83,3 +83,8 @@ SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `
|
|||
- `Force`: 현재 모델 사용 가능 여부와 관계없이 항상 target model 로 이동하고 다음 규칙을 계속 평가
|
||||
- `Fallback`: 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 target model 로 이동하고 다음 규칙을 계속 평가
|
||||
- 활성 rewrite cycle은 저장 시점에 거부되며, `/v1/models` 는 실제 요청 가능한 rewrite alias를 함께 반환한다
|
||||
|
||||
## User Reasoning Compatibility
|
||||
|
||||
- `Users` 화면은 API 키별 `Copy reasoning to reasoning_content` 옵션을 표시하고 편집한다
|
||||
- 이 옵션은 같은 백엔드를 공유하는 사용자라도 downstream 클라이언트 호환성에 맞춰 독립적으로 켜거나 끌 수 있다
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ DB는 `DB_DIR` 하위에 분리 저장된다.
|
|||
| email | TEXT | |
|
||||
| is_active | BOOLEAN | DEFAULT 1 |
|
||||
| detail_logging | INTEGER | NOT NULL DEFAULT 0 |
|
||||
| copy_reasoning_to_reasoning_content | INTEGER | NOT NULL DEFAULT 0 |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ isolated-vm 기반 JavaScript 샌드박스에서 요청/응답을 조작하는
|
|||
|
||||
백엔드 응답 수신 후 실행. 현재 구현에서는 응답 컨텍스트를 검사하거나 로그/부가 처리를 수행하는 용도로 실행되며, 훅이 반환한 변경 내용이 최종 HTTP 응답에 다시 반영되지는 않는다.
|
||||
|
||||
참고: `reasoning` 을 `reasoning_content` 로 복제하는 OpenAI-compatible 호환 처리는 script hook이 아니라 사용자별 라우터 내장 옵션 `copy_reasoning_to_reasoning_content` 로 수행한다.
|
||||
|
||||
## Script Context
|
||||
|
||||
스크립트에서 접근 가능한 데이터:
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ server/src/
|
|||
- `/v1/models` 는 허용 가능한 활성 백엔드들의 native 모델과 실제 요청 가능한 rewrite alias 합집합을 반환한다
|
||||
- `MODEL_LIST_INCLUDE_ROUTING_METADATA` 가 켜져 있으면 `/v1/models` 는 비표준 `kyush_router` metadata를 추가해 요청 모델, 최종 라우팅 모델, 적용된 rewrite path를 노출한다.
|
||||
|
||||
## Reasoning Compatibility
|
||||
|
||||
- 사용자별 `copy_reasoning_to_reasoning_content` 옵션이 켜져 있으면 `/v1/chat/completions` 응답에서 `reasoning` 을 `reasoning_content` 로 추가 복제한다
|
||||
- 같은 백엔드라도 API 키별로 옵션을 다르게 둘 수 있다
|
||||
- streaming 응답은 옵션이 켜진 경우에만 SSE JSON frame을 변환하고, 옵션이 꺼진 경우 기존처럼 원본 바이트를 전달한다
|
||||
- 이미 `reasoning_content` 가 있으면 덮어쓰지 않고 `reasoning` 원본도 유지한다
|
||||
|
||||
참고:
|
||||
- 세부 라우팅 규칙과 캐시 트리거는 [docs/model-routing.md](./model-routing.md) 참고
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ function runCoreMigrations(database: Database.Database): void {
|
|||
if (hasColumn(database, 'model_rewrites', 'force') === false) {
|
||||
database.exec('ALTER TABLE model_rewrites ADD COLUMN force BOOLEAN DEFAULT 0');
|
||||
}
|
||||
if (hasColumn(database, 'users', 'copy_reasoning_to_reasoning_content') === false) {
|
||||
database.exec('ALTER TABLE users ADD COLUMN copy_reasoning_to_reasoning_content INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export class UserModel {
|
|||
static asUser(row: any): User {
|
||||
row.is_active = !!row.is_active;
|
||||
row.detail_logging = !!row.detail_logging;
|
||||
row.copy_reasoning_to_reasoning_content = !!row.copy_reasoning_to_reasoning_content;
|
||||
return row as User;
|
||||
}
|
||||
|
||||
|
|
@ -31,10 +32,11 @@ export class UserModel {
|
|||
const apiKey = data.api_key ?? generateApiKey();
|
||||
const timestamp = getUtcTimestamp();
|
||||
const detailLogging = data.detail_logging ?? false;
|
||||
const copyReasoning = data.copy_reasoning_to_reasoning_content ?? false;
|
||||
const stmt = getDb().prepare(
|
||||
'INSERT INTO users (api_key, name, email, detail_logging, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO users (api_key, name, email, detail_logging, copy_reasoning_to_reasoning_content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(apiKey, data.name, data.email || null, detailLogging ? 1 : 0, timestamp, timestamp);
|
||||
const result = stmt.run(apiKey, data.name, data.email || null, detailLogging ? 1 : 0, copyReasoning ? 1 : 0, timestamp, timestamp);
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid as number,
|
||||
|
|
@ -43,6 +45,7 @@ export class UserModel {
|
|||
email: data.email,
|
||||
is_active: true,
|
||||
detail_logging: detailLogging,
|
||||
copy_reasoning_to_reasoning_content: copyReasoning,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
|
|
@ -72,6 +75,10 @@ export class UserModel {
|
|||
updates.push('detail_logging = ?');
|
||||
values.push(data.detail_logging ? 1 : 0);
|
||||
}
|
||||
if (data.copy_reasoning_to_reasoning_content !== undefined) {
|
||||
updates.push('copy_reasoning_to_reasoning_content = ?');
|
||||
values.push(data.copy_reasoning_to_reasoning_content ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return this.findById(id);
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ router.get('/users', (req: Request, res: Response) => {
|
|||
});
|
||||
|
||||
router.post('/users', (req: Request, res: Response) => {
|
||||
const { name, email, api_key, detail_logging } = req.body as CreateUserData;
|
||||
const { name, email, api_key, detail_logging, copy_reasoning_to_reasoning_content } = req.body as CreateUserData;
|
||||
|
||||
if (!name?.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
|
|
@ -60,6 +60,7 @@ router.post('/users', (req: Request, res: Response) => {
|
|||
email: email?.trim() || undefined,
|
||||
api_key: api_key?.trim() || undefined,
|
||||
detail_logging,
|
||||
copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
|
|
@ -93,7 +94,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const { name, email, api_key, is_active, detail_logging } = req.body as UpdateUserData;
|
||||
const { name, email, api_key, is_active, detail_logging, copy_reasoning_to_reasoning_content } = req.body as UpdateUserData;
|
||||
|
||||
if (typeof name === 'string' && !name.trim()) {
|
||||
res.status(400).json({ error: 'Name cannot be empty' });
|
||||
|
|
@ -107,6 +108,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
|||
api_key: typeof api_key === 'string' ? api_key.trim() || undefined : undefined,
|
||||
is_active,
|
||||
detail_logging,
|
||||
copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ModelCatalogService, ModelRewriteCycleError } from '../services/ModelCa
|
|||
import { getDetailStreamLogMode } from '../config/stream-logging';
|
||||
import { shouldIncludeModelListRoutingMetadata } from '../config/model-list-metadata';
|
||||
import { ChatStreamLogAccumulator } from '../utils/streamLog';
|
||||
import { ReasoningCompatSseTransformer, copyReasoningToReasoningContentInChatCompletion } from '../utils/reasoningCompat';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
|
|
@ -213,6 +214,8 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
|
||||
const reader = backendResponse.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const streamTransformer = user.copy_reasoning_to_reasoning_content ? new ReasoningCompatSseTransformer() : null;
|
||||
req.on('close', () => reader.cancel());
|
||||
|
||||
const detailStreamLogMode = getDetailStreamLogMode();
|
||||
|
|
@ -222,14 +225,28 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(value);
|
||||
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
streamLog.append(text);
|
||||
if (streamTransformer) {
|
||||
const transformedText = streamTransformer.append(text);
|
||||
if (transformedText) {
|
||||
res.write(encoder.encode(transformedText));
|
||||
streamLog.append(transformedText);
|
||||
}
|
||||
} else {
|
||||
res.write(value);
|
||||
streamLog.append(text);
|
||||
}
|
||||
}
|
||||
|
||||
const remainingText = decoder.decode();
|
||||
if (remainingText) {
|
||||
if (streamTransformer) {
|
||||
const transformedText = streamTransformer.append(remainingText) + streamTransformer.flush();
|
||||
if (transformedText) {
|
||||
res.write(encoder.encode(transformedText));
|
||||
streamLog.append(transformedText, false);
|
||||
}
|
||||
} else if (remainingText) {
|
||||
streamLog.append(remainingText, false);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -277,11 +294,14 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
const responseData = user.copy_reasoning_to_reasoning_content
|
||||
? copyReasoningToReasoningContentInChatCompletion(response.data, false)
|
||||
: response.data;
|
||||
|
||||
const responseContext = {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body: response.data,
|
||||
body: responseData,
|
||||
isStream: false,
|
||||
};
|
||||
|
||||
|
|
@ -298,23 +318,23 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
endpoint: '/v1/chat/completions',
|
||||
request_model: model,
|
||||
routed_model: resolution.routedModel,
|
||||
response_model: response.data && typeof response.data === 'object' && 'model' in response.data ? String(response.data.model) : undefined,
|
||||
prompt_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { prompt_tokens?: number } }).usage === 'object' ? (response.data as { usage: { prompt_tokens: number } }).usage?.prompt_tokens : undefined,
|
||||
completion_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { completion_tokens?: number } }).usage === 'object' ? (response.data as { usage: { completion_tokens: number } }).usage?.completion_tokens : undefined,
|
||||
total_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { total_tokens?: number } }).usage === 'object' ? (response.data as { usage: { total_tokens: number } }).usage?.total_tokens : undefined,
|
||||
response_model: responseData && typeof responseData === 'object' && 'model' in responseData ? String(responseData.model) : undefined,
|
||||
prompt_tokens: responseData && typeof responseData === 'object' && 'usage' in responseData && typeof (responseData as { usage?: { prompt_tokens?: number } }).usage === 'object' ? (responseData as { usage: { prompt_tokens: number } }).usage?.prompt_tokens : undefined,
|
||||
completion_tokens: responseData && typeof responseData === 'object' && 'usage' in responseData && typeof (responseData as { usage?: { completion_tokens?: number } }).usage === 'object' ? (responseData as { usage: { completion_tokens: number } }).usage?.completion_tokens : undefined,
|
||||
total_tokens: responseData && typeof responseData === 'object' && 'usage' in responseData && typeof (responseData as { usage?: { total_tokens?: number } }).usage === 'object' ? (responseData as { usage: { total_tokens: number } }).usage?.total_tokens : undefined,
|
||||
status_code: response.status,
|
||||
response_time_ms: responseTime,
|
||||
error_message: response.status >= 400 ? JSON.stringify(response.data) : undefined,
|
||||
error_message: response.status >= 400 ? JSON.stringify(responseData) : undefined,
|
||||
detail_logged: detailLoggingEnabled,
|
||||
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
|
||||
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
|
||||
response_headers: detailLoggingEnabled ? response.headers : undefined,
|
||||
response_body: detailLoggingEnabled ? response.data : undefined,
|
||||
response_body: detailLoggingEnabled ? responseData : undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
const errorDetails = response.data as any;
|
||||
const errorDetails = responseData as any;
|
||||
const errorInfo = errorDetails.error || 'Unknown error';
|
||||
const causeInfo = errorDetails.cause ? ` (Cause: ${errorDetails.cause})` : '';
|
||||
const backendInfo = errorDetails.backend ? ` [Backend: ${errorDetails.backend}]` : '';
|
||||
|
|
@ -322,7 +342,7 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
|
||||
}
|
||||
|
||||
res.status(response.status).json(response.data);
|
||||
res.status(response.status).json(responseData);
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
|
|
|
|||
91
server/src/utils/reasoningCompat.ts
Normal file
91
server/src/utils/reasoningCompat.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function copyReasoningField(target: Record<string, unknown>): boolean {
|
||||
if (typeof target.reasoning !== 'string' || target.reasoning_content !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
target.reasoning_content = target.reasoning;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function copyReasoningToReasoningContentInChatCompletion(payload: unknown, stream: boolean): unknown {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.choices)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
for (const choice of payload.choices) {
|
||||
if (!isRecord(choice)) continue;
|
||||
|
||||
const target = stream ? choice.delta : choice.message;
|
||||
if (isRecord(target)) {
|
||||
copyReasoningField(target);
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export class ReasoningCompatSseTransformer {
|
||||
private buffer = '';
|
||||
|
||||
append(text: string): string {
|
||||
this.buffer = `${this.buffer}${text}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const transformedEvents: string[] = [];
|
||||
|
||||
while (true) {
|
||||
const delimiterIndex = this.buffer.indexOf('\n\n');
|
||||
if (delimiterIndex < 0) break;
|
||||
|
||||
const eventText = this.buffer.slice(0, delimiterIndex);
|
||||
this.buffer = this.buffer.slice(delimiterIndex + 2);
|
||||
transformedEvents.push(this.transformEvent(eventText));
|
||||
}
|
||||
|
||||
return transformedEvents.join('');
|
||||
}
|
||||
|
||||
flush(): string {
|
||||
if (!this.buffer) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const remaining = this.buffer;
|
||||
this.buffer = '';
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private transformEvent(eventText: string): string {
|
||||
const lines = eventText.split('\n');
|
||||
const dataLines = lines
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^ /, ''));
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return `${eventText}\n\n`;
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
if (data.trim() === '[DONE]') {
|
||||
return `${eventText}\n\n`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const before = JSON.stringify(parsed);
|
||||
copyReasoningToReasoningContentInChatCompletion(parsed, true);
|
||||
const after = JSON.stringify(parsed);
|
||||
|
||||
if (before === after) {
|
||||
return `${eventText}\n\n`;
|
||||
}
|
||||
|
||||
const nonDataLines = lines.filter((line) => !line.startsWith('data:'));
|
||||
return `${[...nonDataLines, `data: ${after}`].join('\n')}\n\n`;
|
||||
} catch {
|
||||
return `${eventText}\n\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -230,8 +230,12 @@ export class ChatStreamLogAccumulator {
|
|||
|
||||
if (isRecord(delta)) {
|
||||
if (typeof delta.role === 'string') choice.role = delta.role;
|
||||
if (typeof delta.reasoning === 'string') choice.reasoning.push(delta.reasoning);
|
||||
if (typeof delta.reasoning_content === 'string') choice.reasoning.push(delta.reasoning_content);
|
||||
const reasoning = typeof delta.reasoning_content === 'string'
|
||||
? delta.reasoning_content
|
||||
: typeof delta.reasoning === 'string'
|
||||
? delta.reasoning
|
||||
: undefined;
|
||||
if (reasoning) choice.reasoning.push(reasoning);
|
||||
if (typeof delta.content === 'string') choice.content.push(delta.content);
|
||||
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,17 @@ describe('Admin API - User Management', () => {
|
|||
expect(response.body.email).toBe(userData.email);
|
||||
expect(response.body).toHaveProperty('api_key');
|
||||
expect(response.body.api_key).toMatch(/^sk-/);
|
||||
expect(response.body.copy_reasoning_to_reasoning_content).toBe(false);
|
||||
});
|
||||
|
||||
it('should create a user with reasoning compatibility enabled', async () => {
|
||||
const response = await admin.post('/admin/users').send({
|
||||
name: 'Reasoning Compat User',
|
||||
copy_reasoning_to_reasoning_content: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.copy_reasoning_to_reasoning_content).toBe(true);
|
||||
});
|
||||
|
||||
it('should create a user with a manually supplied api key', async () => {
|
||||
|
|
@ -111,6 +122,15 @@ describe('Admin API - User Management', () => {
|
|||
expect(response.body.api_key).toBe('legacy-updated-key-001');
|
||||
});
|
||||
|
||||
it('should update user reasoning compatibility setting', async () => {
|
||||
const response = await admin
|
||||
.put(`/admin/users/${userId}`)
|
||||
.send({ copy_reasoning_to_reasoning_content: true });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.copy_reasoning_to_reasoning_content).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent user', async () => {
|
||||
const response = await admin.put('/admin/users/99999').send({ name: 'Test' });
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ describe('Streaming Response Proxying', () => {
|
|||
}
|
||||
});
|
||||
|
||||
async function setupUserAndBackend(mockPort: number, options: { detailLogging?: boolean } = {}) {
|
||||
async function setupUserAndBackend(mockPort: number, options: { detailLogging?: boolean; copyReasoning?: boolean } = {}) {
|
||||
// Deactivate all existing backends to ensure only our mock backend is selected
|
||||
const allBackendsResponse = await admin.get('/admin/backends');
|
||||
for (const backend of allBackendsResponse.body) {
|
||||
|
|
@ -87,6 +87,7 @@ describe('Streaming Response Proxying', () => {
|
|||
const userResponse = await admin.post('/admin/users').send({
|
||||
name: `Stream Test User ${Date.now()}`,
|
||||
detail_logging: options.detailLogging,
|
||||
copy_reasoning_to_reasoning_content: options.copyReasoning,
|
||||
});
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
|
@ -102,6 +103,15 @@ describe('Streaming Response Proxying', () => {
|
|||
return { userApiKey, userId, backendId };
|
||||
}
|
||||
|
||||
async function createUserForBackend(backendId: number, options: { copyReasoning?: boolean } = {}) {
|
||||
const userResponse = await admin.post('/admin/users').send({
|
||||
name: `Stream Compat User ${Date.now()} ${Math.random()}`,
|
||||
copy_reasoning_to_reasoning_content: options.copyReasoning,
|
||||
});
|
||||
await admin.post('/admin/permissions').send({ user_id: userResponse.body.id, backend_id: backendId });
|
||||
return { userApiKey: userResponse.body.api_key as string, userId: userResponse.body.id as number };
|
||||
}
|
||||
|
||||
it('should return Content-Type text/event-stream for stream requests', async () => {
|
||||
const { server, port } = createMockBackend({
|
||||
streamChunks: sampleStreamChunks,
|
||||
|
|
@ -378,4 +388,108 @@ describe('Streaming Response Proxying', () => {
|
|||
expect(logsResponse.body.rows[0].response_body).toContain('data: ');
|
||||
expect(logsResponse.body.rows[0].response_body).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('should copy streaming reasoning to reasoning_content only for users with compatibility enabled', async () => {
|
||||
const reasoningStreamChunks = [
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-reasoning-1',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { role: 'assistant', reasoning: 'Think once.' }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-reasoning-1',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { reasoning: ' Keep original.', reasoning_content: 'Existing wins.' }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-reasoning-1',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { content: 'Hello' }, finish_reason: 'stop' }],
|
||||
}),
|
||||
];
|
||||
const { server, port } = createMockBackend({
|
||||
streamChunks: reasoningStreamChunks,
|
||||
modelsResponse: [{ id: 'mock-model', object: 'model' }],
|
||||
});
|
||||
mockServer = server;
|
||||
|
||||
const { backendId, userApiKey: defaultUserApiKey } = await setupUserAndBackend(port);
|
||||
const { userApiKey: compatUserApiKey } = await createUserForBackend(backendId, { copyReasoning: true });
|
||||
|
||||
const defaultResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${defaultUserApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const compatResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${compatUserApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const defaultFirstChunk = JSON.parse(defaultResponse.text.split('\n').find((line: string) => line.startsWith('data: {'))!.replace('data: ', ''));
|
||||
expect(defaultFirstChunk.choices[0].delta.reasoning).toBe('Think once.');
|
||||
expect(defaultFirstChunk.choices[0].delta.reasoning_content).toBeUndefined();
|
||||
|
||||
const compatDataLines = compatResponse.text.split('\n').filter((line: string) => line.startsWith('data: {'));
|
||||
const compatFirstChunk = JSON.parse(compatDataLines[0].replace('data: ', ''));
|
||||
expect(compatFirstChunk.choices[0].delta.reasoning).toBe('Think once.');
|
||||
expect(compatFirstChunk.choices[0].delta.reasoning_content).toBe('Think once.');
|
||||
|
||||
const compatSecondChunk = JSON.parse(compatDataLines[1].replace('data: ', ''));
|
||||
expect(compatSecondChunk.choices[0].delta.reasoning).toBe(' Keep original.');
|
||||
expect(compatSecondChunk.choices[0].delta.reasoning_content).toBe('Existing wins.');
|
||||
expect(compatResponse.text).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('should copy non-stream reasoning to reasoning_content only for users with compatibility enabled', async () => {
|
||||
const { server, port } = createMockBackend({
|
||||
chatResponse: {
|
||||
id: 'non-stream-reasoning-1',
|
||||
model: 'mock-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Hello', reasoning: 'Think non-stream.' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
modelsResponse: [{ id: 'mock-model', object: 'model' }],
|
||||
});
|
||||
mockServer = server;
|
||||
|
||||
const { backendId, userApiKey: defaultUserApiKey } = await setupUserAndBackend(port);
|
||||
const { userApiKey: compatUserApiKey } = await createUserForBackend(backendId, { copyReasoning: true });
|
||||
|
||||
const defaultResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${defaultUserApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
});
|
||||
|
||||
const compatResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${compatUserApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
});
|
||||
|
||||
expect(defaultResponse.body.choices[0].message.reasoning).toBe('Think non-stream.');
|
||||
expect(defaultResponse.body.choices[0].message.reasoning_content).toBeUndefined();
|
||||
expect(compatResponse.body.choices[0].message.reasoning).toBe('Think non-stream.');
|
||||
expect(compatResponse.body.choices[0].message.reasoning_content).toBe('Think non-stream.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export interface MockBackendOptions {
|
|||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: { role: string; content: string };
|
||||
message: { role: string; content: string; reasoning?: string; reasoning_content?: string };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface User {
|
|||
email?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
copy_reasoning_to_reasoning_content: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -102,6 +103,7 @@ export interface CreateUserData {
|
|||
email?: string;
|
||||
api_key?: string;
|
||||
detail_logging?: boolean;
|
||||
copy_reasoning_to_reasoning_content?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateBackendData {
|
||||
|
|
@ -122,6 +124,7 @@ export interface UpdateUserData {
|
|||
api_key?: string;
|
||||
is_active?: boolean;
|
||||
detail_logging?: boolean;
|
||||
copy_reasoning_to_reasoning_content?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateBackendData {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue