Skip to content

feat(assistant): allow agnostic provider - #1427

Merged
larbish merged 4 commits into
mainfrom
feat/assistant-custom-provider
Aug 27, 2026
Merged

feat(assistant): allow agnostic provider#1427
larbish merged 4 commits into
mainfrom
feat/assistant-custom-provider

Conversation

@larbish

@larbish larbish commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Allow the assistant to run with any AI SDK provider instead of hard-coupling its enablement to Vercel AI Gateway credentials.

Closes #1414

Changes

Explicit enabled option (index.ts)

docus.assistant.enabled now overrides credential auto-detection.

Left undefined, behaviour is unchanged: enabled when AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN is present at build time.

// Enable without AI Gateway credentials, bring your own endpoint
docus: { assistant: { enabled: true, apiPath: '/api/assistant' } }

When enabled without Gateway credentials, Docus skips registering its own handler and logs the route to define (a user route matching apiPath also takes precedence over the built-in one).

Reusable handler (runtime/server/utils/assistant.ts, runtime/server/api/search.ts)

Endpoint logic moves out of the route into assistantSearchHandler, an auto-imported server util accepting model, systemPrompt and providerOptions.

getAssistantSystemPrompt is also exported so the default prompt can be extended rather than replaced.

Both stay auto-imported when the assistant is disabled, so overriding the endpoint remains type-safe.

// server/api/assistant.ts
export default defineEventHandler(event => assistantSearchHandler(event, {
  model: mistral('mistral-large-latest')
}))

@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docus Ready Ready Preview Aug 26, 2026 9:25pm

@pkg-pr-new

pkg-pr-new Bot commented Aug 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/create-docus@1427
npm i https://pkg.pr.new/docus@1427

commit: 78e4141

@remihuigen

Copy link
Copy Markdown
Contributor

😃

@larbish

larbish commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@remihuigen Can you ensure it works as expected in your case? Thanks!

@Barbapapazes

Copy link
Copy Markdown
Contributor

@remihuigen Can you ensure it works as expected in your case? Thanks!

I'll take some time this afternoon to give you a feedback @larbish

@remihuigen

Copy link
Copy Markdown
Contributor

I'll take some time this afternoon to give you a feedback @larbish

Same

@Barbapapazes

Barbapapazes commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

I encounter this error

[2:13:51 PM]  ERROR  Assistant search error: { error:
   { APICallError [AI_APICallError]: Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.

It's related to maxOutputTokens: 8000 in streamText.

EDIT: it seems to be related to the unified gateway package.

@Barbapapazes

Copy link
Copy Markdown
Contributor

Another one

 ERROR  Assistant search error: { error:                                2:21:58 PM
   { APICallError [AI_APICallError]: Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.

@Barbapapazes

Copy link
Copy Markdown
Contributor

Ok, it works but for an unknown reason, nothing is displayed

@Barbapapazes

Copy link
Copy Markdown
Contributor

For now, I'm unable to make it work with Cloudflare.

@Barbapapazes

Copy link
Copy Markdown
Contributor
Screen.Recording.2026-08-26.at.14.45.35.mov

The data aren't displayed.

@Barbapapazes

Copy link
Copy Markdown
Contributor
Screenshot 2026-08-26 at 14 48 30

Data is received by the frontend but not displayed

@Barbapapazes

Copy link
Copy Markdown
Contributor

when adding

 <template #content="{ message }">
          {{ message }}
          <template
            v-for="(part, index) in message.parts"
            :key="`${message.id}-${part.type}-${index}`"
          >

the {{ message }} is displayed but not the message. We can see the hello but not the answer.

Screenshot 2026-08-26 at 14 51 30

@remihuigen

Copy link
Copy Markdown
Contributor

I tried running a custom provider. Had the same issue as @Barbapapazes where responses are not rendered.

Other than that, I was wondering:

  • why would you add getAssistantSystemPrompt to auto imports? Either somebody uses the default system prompt, or he provides a custom prompt to the handler. I don't see anybody using getAssistantSystemPrompt("My Site Name Override"). Or you would have to make the system prompt fully configurable (see tiny example below)
  • More on the semantic side: why call it assistantSearchHandler? Its not really search.. Would aiAssistentHandler not be more appropriate? Renaming at this point should not break consumer installations
  • Why not add some more options to AssistantSearchConfig? At minimum, it would be useful to configure maxOutputTokens, maxSteps, temperature. I think Docus should be agnostic as to what these values are (you can keep the same defaults as currently is use). I have provided a more complete conifg interface below

@remihuigen

Copy link
Copy Markdown
Contributor

You could make the system prompt configuration section based (as in the code below), or even rule based (include / exclude individual bullets in sections)

Then again, I personally think not providing getAssistantSystemPrompt as an auto import is more sensible. Somebody who wants to customize the system prompt doesn't need this configuration system

export interface AssistantSystemPromptOptions {
  identity?: boolean | string
  toolUsage?: boolean | string
  guidelines?: boolean | string
  links?: boolean | string
  formatting?: boolean | string
  responseStyle?: boolean | string
}

/**
 * Builds the documentation assistant system prompt.
 *
 * Each prompt section can be:
 * - `true` or omitted: use the default rules
 * - `false`: disable the section
 * - `string`: completely override the default section
 *
 * @param siteName - Name of the documentation project.
 * @param options - Configuration for individual prompt sections.
 * @returns The generated system prompt.
 *
 * @example
 * ```ts
 * getAssistantSystemPrompt('Docus', {
 *   formatting: false,
 *   identity: `
 * You are a technical documentation assistant.
 * You may use first-person language when appropriate.
 *   `,
 * })
 * ```
 */
export function getAssistantSystemPrompt(
  siteName: string,
  options: AssistantSystemPromptOptions = {},
): string {
  const sections: Record<
    keyof AssistantSystemPromptOptions,
    string
  > = {
    identity: `**Your identity:**
- You are an assistant helping users with ${siteName} documentation
- NEVER use first person ("I", "me", "my") - always refer to the project by name: "${siteName} provides...", "${siteName} supports...", "The project offers..."
- Be confident and knowledgeable about the project
- Speak as a helpful guide, not as the documentation itself`,

    toolUsage: `**Tool usage (CRITICAL):**
- You have tools: list-pages (discover pages) and get-page (read a page)
- If a page title clearly matches the question, read it directly without listing first
- ALWAYS respond with text after using tools - never end with just tool calls`,

    guidelines: `**Guidelines:**
- If you can't find something, say "There is no documentation on that yet" or "${siteName} doesn't cover that topic yet"
- Be concise, helpful, and direct
- Guide users like a friendly expert would`,

    links: `**Links and exploration:**
- Tool results include a \`url\` for each page — prefer markdown links \`[label](url)\` so users can open the doc in one click
- When it helps, add extra links (related pages, "read more", side topics) — make the answer easy to dig into, not a wall of text
- Stick to URLs from tool results (\`url\` / \`path\`) so links stay valid`,

    formatting: `**FORMATTING RULES (CRITICAL):**
- NEVER use markdown headings (#, ##, ###, etc.)
- Use **bold text** for emphasis and section labels
- Start responses with content directly, never with a heading
- Use bullet points for lists
- Keep code examples focused and minimal`,

    responseStyle: `**Response style:**
- Conversational but professional
- "Here's how you can do that:" instead of "The documentation shows:"
- "${siteName} supports TypeScript out of the box" instead of "I support TypeScript"
- Provide actionable guidance, not just information dumps`,
  }

  const resolveSection = (
    key: keyof AssistantSystemPromptOptions,
  ): string | null => {
    const value = options[key]

    if (value === false) {
      return null
    }

    if (typeof value === 'string') {
      return value.trim()
    }

    return sections[key]
  }

  const configuredSections = (
    Object.keys(sections) as Array<keyof AssistantSystemPromptOptions>
  )
    .map(resolveSection)
    .filter((section): section is string => section !== null)

  return [
    `You are the documentation assistant for ${siteName}. Help users navigate and understand the project documentation.`,
    ...configuredSections,
  ].join('\n\n')
}
// Default behavior
getAssistantSystemPrompt('Docus')

// Turn formatting restrictions off
getAssistantSystemPrompt('Docus', {
  formatting: false,
})

// Override one set of rules
getAssistantSystemPrompt('Docus', {
  identity: `**Your identity:**
- You are the official Docus assistant
- First-person language is allowed
- Clearly distinguish documented facts from suggestions`,
})

// Mix and match
getAssistantSystemPrompt('Docus', {
  identity: false,
  links: false,
  formatting: `**Formatting:**
- Markdown headings are allowed
- Prefer short sections
- Use code blocks when appropriate`,
})

@remihuigen

Copy link
Copy Markdown
Contributor

A more complete example of config options that can be passed to streamText. I think any option that has influence over the LLM behaviour, should be configurable (temperature, max tokens, etc).

I'm not sure whether UI related stuff like smoothStream should be configurable - I'm not familiar enough with the UI part of the assistent to judge that.

export interface AssistantSearchConfig {
  /**
   * Model used to answer the question.
   *
   * Accepts any AI SDK model, which makes it possible to use a provider other
   * than the Vercel AI Gateway (Cloudflare AI Gateway, Mistral, OpenAI, ...).
   *
   * @default runtimeConfig.assistant.model (resolved through the Vercel AI Gateway)
   */
  model?: LanguageModel;
  /**
   * System prompt sent to the model.
   *
   * Provide a string to fully replace the default prompt, or a function to
   * build it from the request.
   *
   * @default the built-in Docus documentation assistant prompt
   */
  systemPrompt?:
    | string
    | ((event: H3Event, context: AssistantSystemPromptContext) => string);
  /**
   * Provider specific options forwarded to `streamText`.
   *
   * Defaults to Vercel AI Gateway automatic caching, and is omitted when a
   * custom `model` is provided.
   */
  providerOptions?: ProviderOptions;

  /** Maximum number of output tokens per model step. */
  maxOutputTokens: number;
  /** Number of times a failed model request may be retried. */
  maxRetries: number;
  /** Maximum number of model and tool-calling steps. */
  maxSteps: number;
  /** Sampling temperature; avoid combining this with topP unless intentional. */
  temperature?: number;
  /** Nucleus sampling threshold. */
  topP?: number;
  /** Number of highest-probability tokens considered at each step. */
  topK?: number;
  /** Penalty for introducing tokens already present in the response. */
  presencePenalty?: number;
  /** Penalty for repeatedly using tokens already present in the response. */
  frequencyPenalty?: number;
  /** Sequences that stop generation when produced. */
  stopSequences?: string[];
  /** Optional deterministic seed, when supported by the provider. */
  seed?: number;
  /** Request timeout in milliseconds or per-operation timeout settings. */
  timeout?:
    | number
    | { totalMs?: number; stepMs?: number; chunkMs?: number; toolMs?: number };
  /** Additional HTTP headers sent to the model provider. */
  headers?: Record<string, string>;
  /** Controls whether the model may call tools automatically, never, or mandatorily. */
  toolChoice?: "auto" | "none" | "required";
  /** Restricts the configured tool set to these tool names. */
  activeTools?: string[];
  /** Preferred order for sending tools to the provider. */
  toolOrder?: string[];
  /** Controls which request data and raw provider chunks are retained. */
  include?: {
    /** Retain the generated request body in step results. */
    requestBody?: boolean;
    /** Retain converted request messages in step results. */
    requestMessages?: boolean;
    /** Include unprocessed provider chunks in the stream. */
    rawChunks?: boolean;
  };
  /** Enables word- or line-buffered smoothing for streamed output. */
  smoothStream: boolean;
  /** Delay between smoothed stream chunks; null disables the delay. */
  smoothStreamDelayInMs: number | null;
  /** Chunking strategy used by the smoothing transform. */
  smoothStreamChunking: "word" | "line";
}

@larbish

larbish commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@remihuigen @Barbapapazes I forgot to merge main containing a fix for the UI stream. Should be better now.

@larbish

larbish commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Thank you both for the feedback.

Concerning the config interface, I'd rather keep it minimal. Every option we expose is one we document, test and maintain. And there is issues I don't want to raise concerning mixing static options and dynamic ones (some models don't accept all options and vice versa). So instead of growing AssistantSearchConfig to align it with the AI SDD and take the risk it becomes outdated, I propose to drop it.

Same strategy for getAssistantSystemPrompt: adding options is over-engineering it and gives lots of cases to maintain. Either you use the default one, or your own one, or you concat custom rules with the default.

Instead of exposing them all and acting like a (sometimes not up to date) proxy of the SDK, I've provided utils so you don't rebuild the whole endpoint yourself while getting full freedom on customization.

// server/api/assistant.ts
export default defineEventHandler(async (event) => {
  const { messages } = await readBody(event)

  return createAssistantResponse(streamText({
    ...await getAssistantDefaultOptions(event),
    model: mistral('mistral-large-latest'),
    maxOutputTokens: 4000,
    messages: await convertToModelMessages(messages)
  }))
})

createAssistantResponse just wraps the streamText so you own it.

getAssistantDefaultOptions returns every option the built-in endpoint uses (MCP tools, abort, cleanup, prompt, step and token limits) => spread it first, override what you need.

I made a working example on Mistral in playground/server/api/assistant.ts.

Concerning the naming, I'm aligned and updated it across all utils.

Hope it makes sense to you too, please let me know!

@remihuigen

Copy link
Copy Markdown
Contributor

Looks good!

@larbish
larbish merged commit 58ad18f into main Aug 27, 2026
5 checks passed
@larbish
larbish deleted the feat/assistant-custom-provider branch August 27, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support custom AI SDK providers without requiring Vercel AI Gateway

3 participants