[codex] fix media skill profile auth and run events (#965)
* fix media skill profile auth and run events * test bridge run profile context
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import type { Context } from 'koa'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, extname, isAbsolute, join, resolve } from 'path'
|
||||
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||
import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
|
||||
import { config } from '../../config'
|
||||
import { readConfigYaml } from '../../services/config-helpers'
|
||||
import { readConfigYamlForProfile } from '../../services/config-helpers'
|
||||
|
||||
const XAI_VIDEO_GENERATIONS_URL = 'https://api.x.ai/v1/videos/generations'
|
||||
const XAI_VIDEO_STATUS_URL = 'https://api.x.ai/v1/videos'
|
||||
@@ -28,6 +28,42 @@ type FunCodexProvider = {
|
||||
model: string
|
||||
}
|
||||
|
||||
function requestedProfileName(ctx: Context): string {
|
||||
const headerProfile = ctx.get('x-hermes-profile')
|
||||
const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : ''
|
||||
const body = ctx.request.body as { profile?: unknown } | undefined
|
||||
const bodyProfile = typeof body?.profile === 'string' ? body.profile : ''
|
||||
return (ctx.state.profile?.name || headerProfile || queryProfile || bodyProfile || '').trim()
|
||||
}
|
||||
|
||||
function resolveMediaProfile(ctx: Context): string {
|
||||
let requested = requestedProfileName(ctx)
|
||||
if (!requested && ctx.state.user?.role !== 'super_admin' && !ctx.state.serverTokenAuth) {
|
||||
const profiles = ctx.state.user?.profiles || []
|
||||
if (profiles.length === 1) {
|
||||
requested = profiles[0]
|
||||
} else {
|
||||
const err: any = new Error('Profile is required')
|
||||
err.status = 400
|
||||
err.code = 'profile_required'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const profile = requested || getActiveProfileName() || 'default'
|
||||
if (!listProfileNamesFromDisk().includes(profile)) {
|
||||
const err: any = new Error(`Profile "${profile}" does not exist`)
|
||||
err.status = 404
|
||||
err.code = 'profile_not_found'
|
||||
throw err
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
function authPathForProfile(profile: string): string {
|
||||
return join(getProfileDir(profile), 'auth.json')
|
||||
}
|
||||
|
||||
function readJsonFile(path: string): any {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||
@@ -43,8 +79,8 @@ function buildApiUrl(baseUrl: string, pathWithV1: string): string {
|
||||
return `${base}${apiPath}`
|
||||
}
|
||||
|
||||
async function resolveFunCodexProvider(): Promise<FunCodexProvider | null> {
|
||||
const hermesConfig = await readConfigYaml()
|
||||
async function resolveFunCodexProvider(profile: string): Promise<FunCodexProvider | null> {
|
||||
const hermesConfig = await readConfigYamlForProfile(profile)
|
||||
const customProviders = Array.isArray(hermesConfig.custom_providers)
|
||||
? hermesConfig.custom_providers as any[]
|
||||
: []
|
||||
@@ -59,11 +95,11 @@ async function resolveFunCodexProvider(): Promise<FunCodexProvider | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveXaiToken(): { token: string; source: string } | null {
|
||||
function resolveXaiToken(profile: string): { token: string; source: string } | null {
|
||||
const envToken = String(process.env.XAI_API_KEY || '').trim()
|
||||
if (envToken) return { token: envToken, source: 'XAI_API_KEY' }
|
||||
|
||||
const auth = readJsonFile(getActiveAuthPath()) as AuthJson | null
|
||||
const auth = readJsonFile(authPathForProfile(profile)) as AuthJson | null
|
||||
const providerToken = String(auth?.providers?.['xai-oauth']?.tokens?.access_token || auth?.providers?.['xai-oauth']?.access_token || '').trim()
|
||||
if (providerToken) return { token: providerToken, source: 'xai-oauth' }
|
||||
|
||||
@@ -421,11 +457,20 @@ function saveGeneratedImages(images: string[], requestedOutputPath?: string): st
|
||||
}
|
||||
|
||||
export async function apiKeyImageGenerate(ctx: Context) {
|
||||
const provider = await resolveFunCodexProvider()
|
||||
let profile: string
|
||||
try {
|
||||
profile = resolveMediaProfile(ctx)
|
||||
} catch (err: any) {
|
||||
ctx.status = err.status || 400
|
||||
ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' }
|
||||
return
|
||||
}
|
||||
|
||||
const provider = await resolveFunCodexProvider(profile)
|
||||
if (!provider) {
|
||||
ctx.status = 401
|
||||
ctx.body = {
|
||||
error: 'Missing fun-codex provider in active profile config.yaml.',
|
||||
error: `Missing fun-codex provider in profile "${profile}" config.yaml.`,
|
||||
code: 'missing_fun_codex_provider',
|
||||
}
|
||||
return
|
||||
@@ -443,6 +488,7 @@ export async function apiKeyImageGenerate(ctx: Context) {
|
||||
output_paths: outputPaths,
|
||||
provider: APIKEY_IMAGE_PROVIDER,
|
||||
base_url: provider.baseUrl,
|
||||
profile,
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = err.status || 500
|
||||
@@ -484,11 +530,20 @@ async function downloadVideo(url: string, outputPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function grokImageToVideo(ctx: Context) {
|
||||
const tokenInfo = resolveXaiToken()
|
||||
let profile: string
|
||||
try {
|
||||
profile = resolveMediaProfile(ctx)
|
||||
} catch (err: any) {
|
||||
ctx.status = err.status || 400
|
||||
ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' }
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = resolveXaiToken(profile)
|
||||
if (!tokenInfo) {
|
||||
ctx.status = 401
|
||||
ctx.body = {
|
||||
error: 'Missing xAI token. Set XAI_API_KEY or complete xAI OAuth login first.',
|
||||
error: `Missing xAI token for profile "${profile}". Set XAI_API_KEY or complete xAI OAuth login first.`,
|
||||
code: 'missing_xai_token',
|
||||
}
|
||||
return
|
||||
@@ -539,6 +594,7 @@ export async function grokImageToVideo(ctx: Context) {
|
||||
video_url: videoUrl,
|
||||
output_path: outputPath,
|
||||
token_source: tokenInfo.source,
|
||||
profile,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ declare module 'koa' {
|
||||
interface DefaultState {
|
||||
user?: AuthenticatedUser
|
||||
profile?: RequestProfile
|
||||
serverTokenAuth?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +70,19 @@ function requestToken(ctx: Context): string {
|
||||
return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : ''
|
||||
}
|
||||
|
||||
const SERVER_TOKEN_MEDIA_PATHS = new Set([
|
||||
'/api/hermes/media/apikey-image-generate',
|
||||
'/api/hermes/media/grok-image-to-video',
|
||||
])
|
||||
|
||||
async function allowServerTokenForMedia(ctx: Context, token: string): Promise<boolean> {
|
||||
if (!token || !SERVER_TOKEN_MEDIA_PATHS.has(ctx.path)) return false
|
||||
const serverToken = await getToken()
|
||||
if (!serverToken || token !== serverToken) return false
|
||||
ctx.state.serverTokenAuth = true
|
||||
return true
|
||||
}
|
||||
|
||||
export function signUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>, secret: string, now = Date.now()): string {
|
||||
const iat = Math.floor(now / 1000)
|
||||
const payload: JwtPayload = {
|
||||
@@ -149,6 +163,10 @@ export async function requireUserJwt(ctx: Context, next: Next): Promise<void> {
|
||||
const token = requestToken(ctx)
|
||||
const payload = token ? verifyUserJwt(token, secret) : null
|
||||
if (!payload) {
|
||||
if (await allowServerTokenForMedia(ctx, token)) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
ctx.status = 401
|
||||
ctx.body = { error: 'Unauthorized' }
|
||||
return
|
||||
|
||||
@@ -948,7 +948,7 @@ class AgentPool:
|
||||
|
||||
def _tool_progress_callback(self, session_id: str):
|
||||
def callback(event_type, function_name=None, preview=None, function_args=None, **kwargs):
|
||||
if event_type in (None, "tool.started", "tool.completed"):
|
||||
if event_type in (None, "tool.started", "tool.completed") or str(event_type or "").startswith("subagent."):
|
||||
print(
|
||||
"[hermes_bridge] tool_progress_callback "
|
||||
f"session={session_id} event={event_type} tool={function_name} "
|
||||
@@ -964,6 +964,18 @@ class AgentPool:
|
||||
})
|
||||
return
|
||||
|
||||
if str(event_type or "").startswith("subagent."):
|
||||
payload = {
|
||||
"event": str(event_type),
|
||||
"tool_name": str(function_name) if function_name else "",
|
||||
"text": str(preview) if preview is not None else "",
|
||||
"args": _jsonable(function_args) if function_args else {},
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
payload[str(key)] = _jsonable(value)
|
||||
self._append_event(session_id, payload)
|
||||
return
|
||||
|
||||
if event_type == "_thinking":
|
||||
text = function_name
|
||||
if text:
|
||||
|
||||
@@ -473,6 +473,11 @@ class AgentClient {
|
||||
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
|
||||
})
|
||||
: `${routedPrefix}\n\n原始消息:${stripMentionRoutingTokens(msg.content, this.name) || msg.content}`
|
||||
const runContext = [
|
||||
`[Current Hermes profile: ${this.profile}]`,
|
||||
'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.',
|
||||
].join('\n')
|
||||
instructions = instructions ? `${runContext}\n${instructions}` : runContext
|
||||
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
|
||||
? await convertContentBlocksForAgent(input)
|
||||
: input
|
||||
|
||||
@@ -127,11 +127,6 @@ export async function markAbortCompleted(
|
||||
}
|
||||
|
||||
state.events = []
|
||||
replaceState(sessionMap, sessionId, 'abort.completed', {
|
||||
event: 'abort.completed',
|
||||
run_id: runId,
|
||||
synced: true,
|
||||
})
|
||||
emitToSession(nsp, socket, sessionId, 'abort.completed', {
|
||||
event: 'abort.completed',
|
||||
run_id: runId,
|
||||
|
||||
@@ -105,6 +105,7 @@ export async function handleApiRun(
|
||||
sessionMap.set(session_id, state)
|
||||
}
|
||||
state.isWorking = true
|
||||
state.events = []
|
||||
state.profile = profile
|
||||
state.source = 'api_server'
|
||||
state.activeRunMarker = runMarker
|
||||
|
||||
@@ -128,10 +128,12 @@ export async function handleBridgeRun(
|
||||
if (resolvedProvider && sessionRow.provider !== resolvedProvider) updates.provider = resolvedProvider
|
||||
if (Object.keys(updates).length > 0) updateSession(session_id, updates)
|
||||
}
|
||||
if (sessionRow?.workspace) {
|
||||
const workspaceCtx = `[Current working directory: ${sessionRow.workspace}]`
|
||||
fullInstructions = `\n${workspaceCtx}\n${fullInstructions}`
|
||||
}
|
||||
const runContext = [
|
||||
`[Current Hermes profile: ${profile}]`,
|
||||
sessionRow?.workspace ? `[Current working directory: ${sessionRow.workspace}]` : '',
|
||||
'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.',
|
||||
].filter(Boolean).join('\n')
|
||||
fullInstructions = `\n${runContext}\n${fullInstructions}`
|
||||
|
||||
const runMarker = `cli_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -145,6 +147,7 @@ export async function handleBridgeRun(
|
||||
|
||||
state.isWorking = true
|
||||
state.isAborting = false
|
||||
state.events = []
|
||||
state.profile = profile
|
||||
state.source = 'cli'
|
||||
state.activeRunMarker = runMarker
|
||||
@@ -491,6 +494,38 @@ async function applyBridgeChunkAsync(
|
||||
}
|
||||
pushState(sessionMap, sessionId, 'tool.completed', payload)
|
||||
emit('tool.completed', payload)
|
||||
} else if (evType?.startsWith('subagent.')) {
|
||||
const payload = {
|
||||
event: evType,
|
||||
run_id: chunk.run_id,
|
||||
subagent_id: ev.subagent_id,
|
||||
parent_id: ev.parent_id,
|
||||
depth: ev.depth,
|
||||
task_index: ev.task_index,
|
||||
task_count: ev.task_count,
|
||||
goal: ev.goal,
|
||||
model: ev.model,
|
||||
toolsets: ev.toolsets,
|
||||
tool_count: ev.tool_count,
|
||||
tool: ev.tool_name,
|
||||
name: ev.tool_name,
|
||||
preview: ev.text || ev.summary || ev.tool_preview || '',
|
||||
text: ev.text || '',
|
||||
status: ev.status,
|
||||
summary: ev.summary,
|
||||
duration: ev.duration_seconds,
|
||||
duration_seconds: ev.duration_seconds,
|
||||
input_tokens: ev.input_tokens,
|
||||
output_tokens: ev.output_tokens,
|
||||
reasoning_tokens: ev.reasoning_tokens,
|
||||
api_calls: ev.api_calls,
|
||||
cost_usd: ev.cost_usd,
|
||||
files_read: ev.files_read,
|
||||
files_written: ev.files_written,
|
||||
output_tail: ev.output_tail,
|
||||
}
|
||||
pushState(sessionMap, sessionId, evType, payload)
|
||||
emit(evType, payload)
|
||||
} else if (evType === 'turn.boundary') {
|
||||
flushBridgePendingToDb(state, sessionId, runMarker)
|
||||
} else if (evType === 'reasoning.delta' || evType === 'thinking.delta') {
|
||||
|
||||
@@ -168,6 +168,7 @@ export class ChatRunSocket {
|
||||
logger.info('[chat-run-socket] queued run for session %s (queue: %d)', data.session_id, state.queue.length)
|
||||
return
|
||||
}
|
||||
state.events = []
|
||||
state.isWorking = true
|
||||
state.profile = runProfile
|
||||
state.source = source
|
||||
|
||||
Reference in New Issue
Block a user