/** * Z.AI (GLM) web search provider for the DSH web seam (`ctx.web`). * * Registers the `zai-official` search provider, which performs searches through * Z.AI's Anthropic-compatible Messages API using the native `web_search_20250305` * server tool (model `glm-5.3`), authenticating with the `ZAI_API_KEY` * credential from the credentials service. * * Why this exists: the shipped `web-search-deepseek` provider requires a real * DeepSeek API key. On installs whose model route is Z.AI/GLM, that credential * is absent, so `web_search` fails with HTTP 401. Point the `web` row's * `searchProvider` config at `zai-official` to use this provider instead: * * ```yaml * - insert: * - id: web-search-zai * name: dsh-web-search-zai * - id: web * name: '@deepseek-ai/dsh-web' * config: * searchProvider: zai-official * fetchProvider: http * ``` * * Z.AI's Anthropic dialect returns tool results as `tool_result` blocks whose * content is a stringified Python-repr/JSON hybrid rather than standard * `web_search_tool_result` blocks; a tolerant quote-aware scanner extracts * `title`/`link`/`content` records from either shape. Transient upstream * failures ("Internal Network Failure", 5xx, timeouts) are retried. */ const name = 'web-search-zai' const inject = ['web', 'timer'] const PROVIDER_ID = 'zai-official' const ENDPOINT = 'https://api.z.ai/api/anthropic/v1/messages' const MODEL = 'glm-5.3' const API_KEY_REF = 'ZAI_API_KEY' const ANTHROPIC_VERSION = '2023-06-01' const USER_AGENT = 'deepseek-harness/0.0.1' const MAX_TOKENS = 512 const MAX_USES = 3 const MAX_ATTEMPTS = 3 const RETRY_DELAY_MS = 700 const REQUEST_TIMEOUT_MS = 45000 function providerError(message, code) { const error = new Error(message) error.code = code ?? 'WEB_PROVIDER_ERROR' return error } function isAbort(error, signal) { return signal?.aborted === true || (error instanceof Error && error.name === 'AbortError') } /** Resolve ZAI_API_KEY through the credentials service without retaining it. */ async function resolveApiKey(ctx, signal) { if (signal?.aborted === true) throw providerError('Z.AI search aborted', 'WEB_ABORTED') const credentials = ctx.get('credentials') if (credentials !== undefined) { const resolved = await credentials.resolve(API_KEY_REF) if (resolved !== undefined && typeof resolved.value === 'string' && resolved.value.length > 0) return resolved.value } throw providerError( `Z.AI search has no API key: store ${API_KEY_REF} through the credentials service (the web Models page writes it)`, 'WEB_PROVIDER_CREDENTIAL_MISSING' ) } /** * Scan one stringified tool_result payload for {title, link, content} records. * Z.AI emits either Python repr (single quotes, unescaped apostrophes) or JSON * (double quotes), sometimes nested; a quote-aware linear scanner handles both * without a fragile global quote swap. A string literal followed by ':' is a * key; `title` starts a record, `link`/`content` attach to it. */ function extractRecords(text) { const records = [] let current = null let i = 0 const n = text.length const isSpace = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' function readString() { const quote = text[i] i += 1 let out = '' while (i < n) { const ch = text[i] if (ch === '\\' && i + 1 < n) { out += ch + text[i + 1] i += 2 continue } if (ch === quote) { i += 1 return out } out += ch i += 1 } return out } while (i < n) { const ch = text[i] if (ch !== '"' && ch !== "'") { i += 1 continue } const key = readString() let j = i while (j < n && isSpace(text[j])) j += 1 if (text[j] !== ':') continue i = j + 1 while (i < n && isSpace(text[i])) i += 1 if (text[i] !== '"' && text[i] !== "'") continue const value = readString() if (key === 'title') { current = { title: value } records.push(current) } else if (key === 'link' && current !== null && current.link === undefined) { current.link = value } else if (key === 'content' && current !== null && current.content === undefined) { current.content = value } } return records } function addSource(byUrl, url, title, snippet, publishedAt) { if (typeof url !== 'string' || url.length === 0 || !/^https?:\/\//i.test(url) || byUrl.has(url)) return const source = { url } if (typeof title === 'string' && title.length > 0) source.title = title if (typeof snippet === 'string' && snippet.length > 0) { source.snippet = snippet.length > 400 ? snippet.slice(0, 400) + '…' : snippet } if (typeof publishedAt === 'string' && publishedAt.length > 0) source.publishedAt = publishedAt byUrl.set(url, source) } /** Map a Z.AI Messages response body into the normalized WebSearchResult shape. */ function mapResponse(body) { const byUrl = new Map() const blocks = Array.isArray(body?.content) ? body.content : [] for (const block of blocks) { if (block === null || typeof block !== 'object') continue if (block.type === 'web_search_tool_result' && Array.isArray(block.content)) { for (const item of block.content) { if (item !== null && typeof item === 'object' && item.type === 'web_search_result') { addSource(byUrl, item.url, item.title, undefined, item.page_age) } } } else if (block.type === 'tool_result') { const raw = block.content if (typeof raw === 'string') { for (const record of extractRecords(raw)) addSource(byUrl, record.link, record.title, record.content) } else if (Array.isArray(raw)) { for (const item of raw) { if (item !== null && typeof item === 'object' && item.type === 'web_search_result') { addSource(byUrl, item.url, item.title, undefined, item.page_age) } else if (typeof item === 'string') { for (const record of extractRecords(item)) addSource(byUrl, record.link, record.title, record.content) } else if (item !== null && typeof item === 'object' && typeof item.text === 'string') { for (const record of extractRecords(item.text)) addSource(byUrl, record.link, record.title, record.content) } } } } } return { sources: [...byUrl.values()], truncated: false } } /** POST one Messages request with a bounded timeout; returns status and parsed body. */ async function postMessages(apiKey, payload, signal) { const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) const effective = signal !== undefined && typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeout]) : timeout let response try { response = await fetch(ENDPOINT, { method: 'POST', redirect: 'error', signal: effective, headers: { 'x-api-key': apiKey, authorization: `Bearer ${apiKey}`, 'anthropic-version': ANTHROPIC_VERSION, 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT }, body: JSON.stringify(payload) }) } catch (error) { if (isAbort(error, signal)) throw providerError('Z.AI search aborted', 'WEB_ABORTED') throw providerError(`Z.AI search request failed: ${String(error)}`) } const text = await response.text() let body try { body = JSON.parse(text) } catch { body = undefined } return { status: response.status, body, text } } function retryable(message, status) { if (status >= 500) return true return /internal|network|overload|unavailable|timeout|timed out/i.test(message) } /** Register the Z.AI search provider in `ctx.web`. */ function apply(ctx) { const web = ctx.web const provider = { id: PROVIDER_ID, available() { return ctx.get('credentials') !== undefined }, async search(request, signal) { const query = typeof request?.query === 'string' ? request.query.trim() : '' if (query.length === 0) throw providerError('Z.AI search: empty query') const apiKey = await resolveApiKey(ctx, signal) const payload = { model: MODEL, max_tokens: MAX_TOKENS, messages: [ { role: 'user', content: [{ type: 'text', text: `Perform a web search for the query: ${query}` }] } ], tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: MAX_USES }] } try { ctx.get('agents')?.currentInitiator()?.session?.append?.('web/zai-search-llm-request', { endpoint: ENDPOINT, model: MODEL, body: payload }) } catch { /* observability only */ } let lastError for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { if (signal?.aborted === true) throw providerError('Z.AI search aborted', 'WEB_ABORTED') try { const { status, body, text } = await postMessages(apiKey, payload, signal) const apiMessage = typeof body?.error?.message === 'string' ? body.error.message : undefined if (apiMessage !== undefined || status >= 400) { const message = apiMessage ?? text.slice(0, 300) if (retryable(message, status) && attempt < MAX_ATTEMPTS) { await ctx.timeout(RETRY_DELAY_MS) continue } throw providerError(`Z.AI API error (HTTP ${String(status)}): ${message}`) } if (body === undefined) { if (attempt < MAX_ATTEMPTS) { await ctx.timeout(RETRY_DELAY_MS) continue } throw providerError('Z.AI returned an unprocessable response body') } const result = mapResponse(body) if (result.sources.length === 0) { throw providerError('Z.AI returned no web search result blocks; the request may not have triggered native web search') } return result } catch (error) { if (isAbort(error, signal) || error?.code === 'WEB_ABORTED') throw error lastError = error const message = typeof error?.message === 'string' ? error.message : String(error) if (attempt < MAX_ATTEMPTS && retryable(message, 0)) { await ctx.timeout(RETRY_DELAY_MS) continue } throw error } } throw lastError ?? providerError('Z.AI search failed') } } // registerSearchProvider returns a disposer and records its own fiber effect, // so cleanup is automatic when this row's fiber is disposed. web.registerSearchProvider(provider) } export { apply, inject, name }