-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Add Mercury Next Edit (NES) as an opt-in autocomplete mode #10536
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| /** | ||
| * Mercury Next Edit prompt assembly. Lives in the gateway so every client | ||
| * (VS Code, JetBrains, TUI) sends the same structured editor context and the | ||
| * Mercury-specific sentinel format is defined in exactly one place. | ||
| * | ||
| * Tag set is defined by the model and must be reproduced verbatim — see | ||
| * https://docs.inceptionlabs.ai/capabilities/next-edit | ||
| */ | ||
|
|
||
| const RECENTLY_VIEWED_SNIPPETS_OPEN = "<|recently_viewed_code_snippets|>" | ||
| const RECENTLY_VIEWED_SNIPPETS_CLOSE = "<|/recently_viewed_code_snippets|>" | ||
| const RECENTLY_VIEWED_SNIPPET_OPEN = "<|recently_viewed_code_snippet|>" | ||
| const RECENTLY_VIEWED_SNIPPET_CLOSE = "<|/recently_viewed_code_snippet|>" | ||
| const CURRENT_FILE_CONTENT_OPEN = "<|current_file_content|>" | ||
| const CURRENT_FILE_CONTENT_CLOSE = "<|/current_file_content|>" | ||
| const CODE_TO_EDIT_OPEN = "<|code_to_edit|>" | ||
| const CODE_TO_EDIT_CLOSE = "<|/code_to_edit|>" | ||
| const EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>" | ||
| const EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>" | ||
| const CURSOR = "<|cursor|>" | ||
| /** Trailing token that tells the model this is a next-edit (not chat) request. */ | ||
| const UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>" | ||
|
|
||
| export interface MercuryRecentSnippet { | ||
| filepath: string | ||
| content: string | ||
| } | ||
|
|
||
| /** Editor-derived context a client sends; the gateway turns it into a prompt. */ | ||
| export interface MercuryEditContext { | ||
| currentFilePath: string | ||
| currentFileContent: string | ||
| cursorLine: number | ||
| cursorCharacter: number | ||
| editableRegionStartLine: number | ||
| editableRegionEndLine: number | ||
| recentlyViewedSnippets: MercuryRecentSnippet[] | ||
| editDiffHistory: string[] | ||
| } | ||
|
|
||
| function insertCursorToken(lines: string[], cursorLine: number, cursorCharacter: number): string[] { | ||
| if (cursorLine < 0 || cursorLine >= lines.length) return lines | ||
| const line = lines[cursorLine] | ||
| const safeChar = Math.min(Math.max(cursorCharacter, 0), line.length) | ||
| const next = line.slice(0, safeChar) + CURSOR + line.slice(safeChar) | ||
| return [...lines.slice(0, cursorLine), next, ...lines.slice(cursorLine + 1)] | ||
| } | ||
|
|
||
| export function recentlyViewedSnippetsBlock(snippets: MercuryRecentSnippet[]): string { | ||
| const inner = snippets | ||
| .map((s) => | ||
| [RECENTLY_VIEWED_SNIPPET_OPEN, `code_snippet_file_path: ${s.filepath}`, s.content, RECENTLY_VIEWED_SNIPPET_CLOSE].join("\n"), | ||
| ) | ||
| .join("\n") | ||
| return [RECENTLY_VIEWED_SNIPPETS_OPEN, inner, RECENTLY_VIEWED_SNIPPETS_CLOSE].join("\n") | ||
| } | ||
|
|
||
| export function currentFileContentBlock( | ||
| currentFilePath: string, | ||
| currentFileContent: string, | ||
| editableRegionStartLine: number, | ||
| editableRegionEndLine: number, | ||
| cursorLine: number, | ||
| cursorCharacter: number, | ||
| ): string { | ||
| const rawLines = currentFileContent.split("\n") | ||
| const withCursor = insertCursorToken(rawLines, cursorLine, cursorCharacter) | ||
| const start = Math.max(0, Math.min(editableRegionStartLine, withCursor.length)) | ||
| const end = Math.max(start, Math.min(editableRegionEndLine, withCursor.length - 1)) | ||
| const instrumented = [ | ||
| ...withCursor.slice(0, start), | ||
| CODE_TO_EDIT_OPEN, | ||
| ...withCursor.slice(start, end + 1), | ||
| CODE_TO_EDIT_CLOSE, | ||
| ...withCursor.slice(end + 1), | ||
| ] | ||
| return [CURRENT_FILE_CONTENT_OPEN, `current_file_path: ${currentFilePath}`, instrumented.join("\n"), CURRENT_FILE_CONTENT_CLOSE].join("\n") | ||
| } | ||
|
|
||
| export function editDiffHistoryBlock(diffs: string[]): string { | ||
| // Each unidiff from `diff.createPatch` opens with an Index line + separator we | ||
| // strip. Diffs are blank-line separated so the model reads them as distinct hunks. | ||
| const trimmed = diffs.map((d) => { | ||
| const lines = d.split("\n") | ||
| return lines.length > 2 ? lines.slice(2).join("\n") : d | ||
| }) | ||
| return [EDIT_DIFF_HISTORY_OPEN, trimmed.join("\n\n"), EDIT_DIFF_HISTORY_CLOSE].join("\n") | ||
| } | ||
|
|
||
| export function buildMercuryEditPrompt(ctx: MercuryEditContext): string { | ||
| return [ | ||
| recentlyViewedSnippetsBlock(ctx.recentlyViewedSnippets), | ||
| "", | ||
| currentFileContentBlock( | ||
| ctx.currentFilePath, | ||
| ctx.currentFileContent, | ||
| ctx.editableRegionStartLine, | ||
| ctx.editableRegionEndLine, | ||
| ctx.cursorLine, | ||
| ctx.cursorCharacter, | ||
| ), | ||
| "", | ||
| editDiffHistoryBlock(ctx.editDiffHistory), | ||
| "", | ||
| UNIQUE_TOKEN, | ||
| ].join("\n") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { getAutocompleteModel, type DirectAutocompleteProviderID } from "./autocomplete.js" | ||
|
|
||
| /** | ||
| * Env var(s) consulted as a fallback for BYOK keys when the provider hasn't | ||
| * been authenticated via the gateway's Auth store. Mirrors `DIRECT_FIM_ENV`. | ||
| */ | ||
| export const DIRECT_EDIT_ENV: Record<DirectAutocompleteProviderID, string[]> = { | ||
| mistral: ["MISTRAL_API_KEY"], | ||
| inception: ["INCEPTION_API_KEY"], | ||
| } | ||
|
|
||
| export type EditTarget = | ||
| | { provider: "inception"; model: string; url: string } | ||
| | { provider: "kilo"; model: string; url: string } | ||
|
|
||
| /** Shape of the upstream (Mercury) chat/edit completion response we read from. */ | ||
| export interface EditUpstreamResponse { | ||
| choices?: Array<{ message?: { content?: string } }> | ||
| usage?: { prompt_tokens?: number; completion_tokens?: number } | ||
| } | ||
|
|
||
| const INCEPTION_EDIT_URL = "https://api.inceptionlabs.ai/v1/edit/completions" | ||
|
|
||
| /** | ||
| * Pick the upstream edit endpoint for a (provider, model) pair. Only Inception | ||
| * is wired up today — Mercury is the only model family with a documented | ||
| * /v1/edit/completions endpoint. Mistral does not expose a comparable surface. | ||
| */ | ||
| export function resolveEditTarget(provider?: string, model?: string): EditTarget { | ||
| const info = getAutocompleteModel(provider, model) | ||
| if (info.kind === "edit" && info.directProvider === "inception") { | ||
| return { provider: "inception", model: info.requestModel, url: INCEPTION_EDIT_URL } | ||
| } | ||
| // Kilo Gateway does not currently proxy an edit endpoint; callers should | ||
| // fall back to FIM. We still return a kilo target so the handler can surface | ||
| // a 400 rather than silently routing somewhere unexpected. | ||
| return { provider: "kilo", model: info.requestModel, url: "" } | ||
| } | ||
|
|
||
| /** | ||
| * Mercury wraps the rewritten editable region in a triple-backtick fence, | ||
| * sometimes with a language tag and sometimes with `<|code_to_edit|>` sentinels | ||
| * inside. Strip all of that down to the bare code. Shared by both the hono and | ||
| * the Effect HttpApi edit handlers so the parsing can't drift between them. | ||
| */ | ||
| export function extractFencedBody(message: string): string { | ||
| if (!message) return "" | ||
| const fenceOpen = message.indexOf("```") | ||
| if (fenceOpen === -1) return message | ||
| const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) | ||
| if (afterFenceOpen === -1) return "" | ||
| // A missing closing fence means the response was truncated (max_tokens hit). | ||
| // Take everything after the opening fence rather than dropping the suggestion. | ||
| const fenceClose = message.indexOf("```", afterFenceOpen + 1) | ||
| let body = fenceClose === -1 ? message.slice(afterFenceOpen + 1) : message.slice(afterFenceOpen + 1, fenceClose) | ||
| if (body.endsWith("\n")) body = body.slice(0, -1) | ||
| body = body.replace(/^<\|code_to_edit\|>\n?/, "") | ||
| body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") | ||
| return body | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget, type EditTarget, type EditUpstreamResponse } from "../edit.js" | ||
| import { buildMercuryEditPrompt, type MercuryEditContext } from "../edit-prompt.js" | ||
| import type { DirectAutocompleteProviderID } from "../autocomplete.js" | ||
| import type { AuthStore } from "./handlers.js" | ||
|
|
||
| type Auth = Pick<AuthStore, "get"> | ||
|
|
||
| const EDIT_TIMEOUT_MS = 30_000 | ||
| const MAX_TOKENS_DEFAULT = 512 | ||
|
|
||
| async function getProviderKey(Auth: Auth, provider: DirectAutocompleteProviderID): Promise<string | undefined> { | ||
| const auth = await Auth.get(provider) | ||
| if (auth?.type === "api") return auth.key | ||
| return DIRECT_EDIT_ENV[provider].map((key) => process.env[key]).find(Boolean) | ||
| } | ||
|
|
||
| export function createEditHandler(Auth: Auth) { | ||
| return async (c: any) => { | ||
| const { provider, model, maxTokens, ...context } = c.req.valid("json") | ||
| const target = resolveEditTarget(provider, model) | ||
|
|
||
| if (target.provider !== "inception") { | ||
| return c.json({ error: "Next Edit currently requires the Inception provider (mercury-edit-2)." }, 400 as any) | ||
| } | ||
|
|
||
| const token = await getProviderKey(Auth, target.provider) | ||
| if (!token) { | ||
| return c.json({ error: `Missing ${target.provider} provider API key` }, 401 as any) | ||
| } | ||
|
|
||
| // Build the Mercury sentinel prompt here so every client only sends | ||
| // structured editor context. | ||
| const content = buildMercuryEditPrompt(context as MercuryEditContext) | ||
| const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(EDIT_TIMEOUT_MS)]) | ||
| console.info(`[EDIT] request provider=${target.provider} model=${target.model} url=${target.url} chars=${content.length}`) | ||
|
|
||
| let response: Response | ||
| try { | ||
| response = await fetch(target.url, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${token}`, | ||
| }, | ||
| signal, | ||
| body: JSON.stringify({ | ||
| model: target.model, | ||
| max_tokens: maxTokens ?? MAX_TOKENS_DEFAULT, | ||
| // Mercury rejects role:"system" on this endpoint — must be a single | ||
| // user message. See the integration's constants.ts for context. | ||
| messages: [{ role: "user", content }], | ||
| }), | ||
| }) | ||
| } catch (err) { | ||
| if (err instanceof DOMException && err.name === "TimeoutError") { | ||
| return c.json({ error: "Edit request timed out" }, 504 as any) | ||
| } | ||
| if (signal.aborted) return c.json({ error: "Edit request canceled" }, 499 as any) | ||
| throw err | ||
| } | ||
|
|
||
| if (!response.ok) { | ||
| const text = await safeText(response) | ||
| return c.json({ error: `Edit request failed: ${response.status} ${text}` }, response.status as any) | ||
| } | ||
|
|
||
| const json = (await response.json()) as EditUpstreamResponse | ||
| const replyContent = json.choices?.[0]?.message?.content ?? "" | ||
| const body = extractFencedBody(replyContent) | ||
| return c.json({ | ||
| content: body, | ||
| usage: json.usage | ||
| ? { | ||
| prompt_tokens: json.usage.prompt_tokens, | ||
| completion_tokens: json.usage.completion_tokens, | ||
| } | ||
| : undefined, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| async function safeText(res: Response): Promise<string> { | ||
| try { | ||
| return await res.text() | ||
| } catch { | ||
| return "<unreadable>" | ||
| } | ||
| } | ||
|
|
||
| // Re-export the target type for tests + the opencode handler | ||
| export type { EditTarget } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING:
console.infofires on every NES edit request via the gateway server's stdout. This is the same class of issue previously noted forfim.ts. Gateway logs should use a structured logger or be gated on a debug flag — unconditionalconsole.infowill appear in server logs on every keystroke trigger. Consider removing or replacing with thenesLog/debug-gated pattern used on the VSCode side.