feat(vscode): discover speech-to-text models

This commit is contained in:
marius-kilocode
2026-08-04 10:06:40 +02:00
parent 6ae16f9d62
commit 744c5c4a6b
30 changed files with 539 additions and 27 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Discover available speech-to-text models from the Kilo Gateway while retaining offline fallback support and organization model restrictions.
+57
View File
@@ -1,6 +1,7 @@
import { z } from "zod"
import { getKiloUrlFromToken } from "../auth/token.js"
import { getDefaultHeaders, buildKiloHeaders } from "../headers.js"
import { resolveKiloGatewayBaseUrl } from "./url.js"
import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js"
export type KiloModelsResult = {
@@ -121,6 +122,16 @@ export type KiloImageModelsResult = {
error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
}
export type KiloTranscriptionModel = {
id: string
name: string
}
export type KiloTranscriptionModelsResult = {
models: KiloTranscriptionModel[]
error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
}
/**
* Fetch image-capable models from Kilo API (OpenRouter-compatible endpoint).
* Uses the same raw fetch as {@link fetchKiloModels} but keeps only models
@@ -145,6 +156,52 @@ export async function fetchKiloImageModels(options?: {
return { models }
}
export async function fetchKiloTranscriptionModels(options?: {
kilocodeToken?: string
kilocodeOrganizationId?: string
baseURL?: string
}): Promise<KiloTranscriptionModelsResult> {
const token = options?.kilocodeToken
const organizationId = options?.kilocodeOrganizationId
const url = new URL("transcription-models", resolveKiloGatewayBaseUrl({ baseURL: options?.baseURL, token }))
const response = await fetch(url, {
headers: {
...getDefaultHeaders(),
...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
}).catch((err: unknown) => err as Error)
if (response instanceof Error) return { models: [], error: { kind: "network" } }
if (!response.ok) {
const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http"
return { models: [], error: { kind, status: response.status } }
}
const json = await response.json().catch(() => null)
if (!json || !Array.isArray(json.data)) return { models: [], error: { kind: "schema" } }
const data: unknown[] = json.data
const models = data.filter(isTranscriptionModel).map((model) => ({
id: model.id,
name: model.name,
}))
if (models.length === 0) return { models: [], error: { kind: "schema" } }
return { models }
}
type TranscriptionModelResponse = {
id: string
name: string
}
function isTranscriptionModel(value: unknown): value is TranscriptionModelResponse {
if (!value || typeof value !== "object") return false
const model = value as Record<string, unknown>
return typeof model.id === "string" && typeof model.name === "string"
}
/**
* Shared raw fetch + validate used by both {@link fetchKiloModels} and {@link fetchKiloImageModels}.
*/
+3
View File
@@ -41,6 +41,9 @@ export {
fetchKiloImageModels,
type KiloImageModel,
type KiloImageModelsResult,
fetchKiloTranscriptionModels,
type KiloTranscriptionModel,
type KiloTranscriptionModelsResult,
} from "./api/models.js"
export {
EMPTY_KILO_EMBEDDING_MODEL_CATALOG,
+54 -1
View File
@@ -1,7 +1,7 @@
// Verifies fetchKiloModels typed result and 401 fallback behaviour.
import { test, expect } from "bun:test"
import { fetchKiloModels } from "../../src/api/models.js"
import { fetchKiloModels, fetchKiloTranscriptionModels } from "../../src/api/models.js"
const VALID_RESPONSE = JSON.stringify({
data: [
@@ -330,3 +330,56 @@ test("keeps image-output models with tools and drops models without tools", asyn
expect(result.models["test/model-a"]).toBeDefined()
expect(result.models["test/no-tools"]).toBeUndefined()
})
test("fetches and filters the transcription catalog", async () => {
const orig = globalThis.fetch
const calls: string[] = []
stubFetch(async (input) => {
calls.push(String(input))
return new Response(
JSON.stringify({
data: [
{
id: "fish-audio/transcribe-1",
name: "Fish Audio: Transcribe 1",
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
)
})
const result = await fetchKiloTranscriptionModels({ kilocodeToken: "token" })
;(globalThis as any).fetch = orig
expect(result.error).toBeUndefined()
expect(result.models).toEqual([
{
id: "fish-audio/transcribe-1",
name: "Fish Audio: Transcribe 1",
},
])
expect(calls[0]).toContain("/api/gateway/transcription-models")
})
test("keeps organization catalog errors from silently falling back to personal models", async () => {
const orig = globalThis.fetch
const calls: string[] = []
const headers: Headers[] = []
stubFetch(async (input, init) => {
calls.push(String(input))
headers.push(new Headers(init?.headers))
return new Response("Forbidden", { status: 403 })
})
const result = await fetchKiloTranscriptionModels({ kilocodeToken: "token", kilocodeOrganizationId: "org-1" })
;(globalThis as any).fetch = orig
expect(result.models).toEqual([])
expect(result.error?.kind).toBe("unauthorized")
expect(calls).toHaveLength(1)
expect(calls[0]).toContain("/api/gateway/transcription-models")
expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1")
})
+13
View File
@@ -169,6 +169,8 @@ import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/projec
import { indexingConsentStore, registeredProjects } from "./indexing-consent"
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
import { fetchImageModels } from "./image-generation/models"
import { fetchSpeechToTextModels } from "./speech-to-text/catalog"
import { SPEECH_TO_TEXT_MODELS } from "./speech-to-text/models"
import { stopSessionProcesses } from "./kilo-provider/background-process"
import { sandboxDefault, sandboxSessionMetadata } from "./shared/sandbox-session"
import {
@@ -1021,6 +1023,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID),
copy: (text) => vscode.env.clipboard.writeText(text),
openSessions: (ids) => this.trackOpenSessions(ids),
speechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
})
) {
return
@@ -2701,6 +2704,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage(message)
}
private async fetchAndSendSpeechToTextModels(): Promise<void> {
const result = await fetchSpeechToTextModels(this.connectionService, this.getWorkspaceDirectory())
if (!result.ok) {
this.postMessage({ type: "speechToTextModelsLoaded" as const, models: [...SPEECH_TO_TEXT_MODELS] })
return
}
this.postMessage({ type: "speechToTextModelsLoaded" as const, models: result.models })
}
/**
* Seed sessionStatusMap with current session statuses on connect.
* Without this, the Settings panel (which has no tracked sessions) would see
@@ -3944,6 +3956,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
disposeGlobal: () => this.disposeGlobal(),
fetchAndSendProviders: () => this.fetchAndSendProviders(),
fetchAndSendAgents: () => this.fetchAndSendAgents(),
fetchAndSendSpeechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
}
}
@@ -17,6 +17,7 @@ type Ctx = {
exportTranscript: (sessionID: string) => Promise<void>
copy: (text: string) => PromiseLike<void>
openSessions: (ids: string[]) => void
speechToTextModels: () => Promise<void>
}
export async function routeEarlyMessage(
@@ -64,6 +65,10 @@ export async function routeEarlyMessage(
ctx.post(buildThroughputSettingMessage())
return true
}
if (message.type === "requestSpeechToTextModels") {
await ctx.speechToTextModels()
return true
}
if (message.type === "requestBrowserSettings") {
ctx.browserSettings()
return true
@@ -15,6 +15,7 @@ export interface AuthContext {
disposeGlobal(): Promise<void>
fetchAndSendProviders(): Promise<void>
fetchAndSendAgents(): Promise<void>
fetchAndSendSpeechToTextModels(): Promise<void>
}
/**
@@ -137,6 +138,11 @@ export async function handleSetOrganization(ctx: AuthContext, organizationId: st
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to refresh agents after org switch:", error)
}
try {
await ctx.fetchAndSendSpeechToTextModels()
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to refresh speech-to-text models after org switch:", error)
}
}
/** Handle profile refresh request. */
@@ -0,0 +1,66 @@
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
import { getErrorMessage } from "../kilo-provider-utils"
import { type SpeechToTextModelDef } from "./models"
const PATH = "/kilo/models/transcriptions"
type CatalogModel = {
id: string
name: string
}
export type SpeechToTextCatalogResult = { ok: true; models: SpeechToTextModelDef[] } | { ok: false; error: string }
export async function fetchSpeechToTextModels(
connection: KiloConnectionService,
dir: string,
signal?: AbortSignal,
): Promise<SpeechToTextCatalogResult> {
const cfg = connection.getServerConfig()
if (!cfg) return fail("Not connected to the Kilo backend")
const auth = Buffer.from(`kilo:${cfg.password}`).toString("base64")
const url = new URL(PATH, cfg.baseUrl)
if (dir) url.searchParams.set("directory", dir)
try {
const res = await fetch(url, { signal, headers: { Authorization: `Basic ${auth}` } })
if (!res.ok) return fail(`Failed to fetch speech-to-text models (HTTP ${res.status})`)
const models = parseSpeechToTextCatalog(await res.json())
if (!models) return fail("Invalid speech-to-text model catalog")
return { ok: true, models }
} catch (err) {
return fail(getErrorMessage(err))
}
}
function fail(error: string): SpeechToTextCatalogResult {
return { ok: false, error }
}
export function parseSpeechToTextCatalog(body: unknown): SpeechToTextModelDef[] | undefined {
if (!Array.isArray(body)) return undefined
const models = body.filter(isCatalogModel).map(toModel)
return models.length > 0 ? models : undefined
}
function isCatalogModel(value: unknown): value is CatalogModel {
if (!value || typeof value !== "object") return false
const model = value as Record<string, unknown>
return typeof model.id === "string" && typeof model.name === "string"
}
function toModel(model: CatalogModel): SpeechToTextModelDef {
const provider = model.name.split(":", 1)[0]?.trim() || model.id.split("/", 1)[0] || "Kilo Gateway"
return {
id: model.id,
label: model.name.includes(":") ? model.name.slice(model.name.indexOf(":") + 1).trim() : model.name,
provider,
...(isVerbatim(model.id) ? { verbatim: true } : {}),
}
}
function isVerbatim(id: string): boolean {
return id === "openai/gpt-4o-mini-transcribe" || id === "openai/gpt-4o-transcribe"
}
@@ -54,7 +54,7 @@ export async function transcribeSpeech(
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model.id,
model: input.model ?? model.id,
input_audio: {
data: input.data,
format: input.format,
@@ -23,9 +23,11 @@ describe("speech-to-text availability", () => {
})
it("normalizes configured and unknown transcription models", () => {
expect(selectedSpeechToTextModel({ experimental: { speech_to_text_model: "google/chirp-3" } })).toBe(
"google/chirp-3",
)
expect(
selectedSpeechToTextModel({ experimental: { speech_to_text_model: "google/chirp-3" } }, [
{ id: "google/chirp-3", label: "Chirp 3", provider: "Google" },
]),
).toBe("google/chirp-3")
expect(selectedSpeechToTextModel({ experimental: { speech_to_text_model: "unknown/model" } })).toBe(
DEFAULT_SPEECH_TO_TEXT_MODEL.id,
)
@@ -0,0 +1,35 @@
import { describe, expect, it } from "bun:test"
import { parseSpeechToTextCatalog } from "../../src/speech-to-text/catalog"
import { DEFAULT_SPEECH_TO_TEXT_MODEL } from "../../src/speech-to-text/models"
describe("speech-to-text discovery", () => {
it("keeps transcription catalog metadata authoritative and exposes additions", () => {
const models = parseSpeechToTextCatalog([
{
id: "fish-audio/transcribe-1",
name: "Fish Audio: Transcribe 1",
},
{
id: "openai/gpt-4o-mini-transcribe",
name: "OpenAI: GPT-4o Mini Transcribe",
},
])
expect(models).toEqual([
{ id: "fish-audio/transcribe-1", label: "Transcribe 1", provider: "Fish Audio" },
{
id: "openai/gpt-4o-mini-transcribe",
label: "GPT-4o Mini Transcribe",
provider: "OpenAI",
verbatim: true,
},
])
expect(models?.find((model) => model.id === "openai/gpt-4o-mini-transcribe")?.verbatim).toBe(true)
})
it("rejects empty or malformed catalogs so callers can use the static fallback", () => {
expect(parseSpeechToTextCatalog([])).toBeUndefined()
expect(parseSpeechToTextCatalog({ data: [] })).toBeUndefined()
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("openai/whisper-large-v3-turbo")
})
})
@@ -21,6 +21,7 @@ import { useProvider } from "../src/context/provider"
import { useConfig } from "../src/context/config"
import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability"
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
import { useSpeechToTextModels } from "../src/context/speech-to-text-models"
import {
getDirectory,
getFilename,
@@ -111,8 +112,9 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const provider = useProvider()
const { config } = useConfig()
const speech = useSpeechToText(vscode, server, { t })
const speechModels = useSpeechToTextModels()
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
const speechModel = () => selectedSpeechToTextModel(config())
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
const sendAllKeybind = () =>
isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other")
@@ -39,6 +39,7 @@ import {
import { useLanguage } from "../src/context/language"
import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments"
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
import { useSpeechToTextModels } from "../src/context/speech-to-text-models"
import { convertToMentionPath } from "../src/utils/path-mentions"
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
@@ -130,8 +131,9 @@ export const NewWorktreeDialog: Component<{
const sandboxRequestID = crypto.randomUUID()
const sandboxVisible = () => features().sandboxControls && globalConfig().sandbox?.enabled === true
const speech = useSpeechToText(vscode, server, { t })
const speechModels = useSpeechToTextModels()
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
const speechModel = () => selectedSpeechToTextModel(config())
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
let prior: string | null = null
let request: string | undefined
const cancel = () => {
@@ -24,6 +24,7 @@ import type { DiffViewerNotice } from "../src/types/messages/extension-messages"
import { DiffPickerHeader } from "./DiffPickerHeader"
import { BaseBranchPicker } from "./BaseBranchPicker"
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"
import { SpeechToTextModelsProvider } from "../src/context/speech-to-text-models"
const NOTICE_KEYS: Record<DiffViewerNotice, string> = {
"snapshots-disabled": "diffViewer.notice.snapshotsDisabled",
@@ -306,8 +307,10 @@ export const DiffViewerApp: Component = () => {
<ServerProvider>
<ProviderProvider>
<ConfigProvider>
<SpeechToTextPrewarm />
<DiffViewerShell />
<SpeechToTextModelsProvider>
<SpeechToTextPrewarm />
<DiffViewerShell />
</SpeechToTextModelsProvider>
</ConfigProvider>
</ProviderProvider>
</ServerProvider>
@@ -27,6 +27,7 @@ import { useProvider } from "../src/context/provider"
import { useConfig } from "../src/context/config"
import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability"
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
import { useSpeechToTextModels } from "../src/context/speech-to-text-models"
import { FileTree } from "./FileTree"
import { treeOrder } from "./file-tree-utils"
import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments"
@@ -113,8 +114,9 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
const provider = useProvider()
const { config } = useConfig()
const speech = useSpeechToText(vscode, server, { t })
const speechModels = useSpeechToTextModels()
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
const speechModel = () => selectedSpeechToTextModel(config())
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
const sendAllKeybind = () =>
isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other")
@@ -34,6 +34,7 @@ import { hasGitChangesMention } from "../../hooks/git-changes-context-utils"
import { useSlashCommand } from "../../hooks/useSlashCommand"
import { useGhostText } from "../../hooks/useGhostText"
import { useSpeechToText } from "../speech-to-text/useSpeechToText"
import { useSpeechToTextModels } from "../../context/speech-to-text-models"
import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments"
import { convertToMentionPath } from "../../utils/path-mentions"
import { SessionMentionPicker } from "./SessionMentionPicker"
@@ -341,6 +342,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const ghost = useGhostText(vscode, text, () => server.isConnected())
const speech = useSpeechToText(vscode, server, language)
const speechModels = useSpeechToTextModels()
const replaceReviewComments = (next: ReviewComment[]) => {
setReviewComments(next)
@@ -498,7 +500,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
const isDisabled = () => !server.isConnected()
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
const speechModel = () => selectedSpeechToTextModel(config())
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
const canSend = () =>
!isDisabled() &&
@@ -7,13 +7,14 @@ import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import { useProvider } from "../../context/provider"
import { useSession } from "../../context/session"
import { useSpeechToTextModels } from "../../context/speech-to-text-models"
import { parseModelString } from "../../../../src/shared/provider-model"
import { ModelSelectorBase } from "../shared/ModelSelector"
import { ThinkingSelectorBase } from "../shared/ThinkingSelector"
import SettingsRow from "./SettingsRow"
import { DEFAULT_SPEECH_TO_TEXT_MODEL } from "../../../../src/speech-to-text/models"
import { hasSpeechToTextAccess, selectedSpeechToTextModel } from "../speech-to-text/availability"
import { SPEECH_TO_TEXT_MODEL_OPTIONS } from "../speech-to-text/model-selector"
import { speechToTextModelOptions } from "../speech-to-text/model-selector"
import { AUTOCOMPLETE_SELECTOR_MODELS, getAutocompleteSelection } from "./autocomplete-model-selector"
const ModelsTab: Component = () => {
@@ -21,6 +22,7 @@ const ModelsTab: Component = () => {
const language = useLanguage()
const provider = useProvider()
const session = useSession()
const speechModels = useSpeechToTextModels()
const autocompleteProvider = () => {
const v = settings()["autocomplete.provider"]
@@ -42,8 +44,9 @@ const ModelsTab: Component = () => {
}
const subagentModel = createMemo(() => parseModelString(config().subagent_model ?? undefined))
const speechModel = createMemo(() => selectedSpeechToTextModel(config()))
const speechOption = createMemo(() => SPEECH_TO_TEXT_MODEL_OPTIONS.find((item) => item.value === speechModel()))
const speechModel = createMemo(() => selectedSpeechToTextModel(config(), speechModels.models()))
const speechOptions = createMemo(() => speechToTextModelOptions(speechModels.models()))
const speechOption = createMemo(() => speechOptions().find((item) => item.value === speechModel()))
const kiloReady = createMemo(() => hasSpeechToTextAccess(config(), provider.authStates()))
const variantKey = createMemo(() => config().subagent_model ?? undefined)
const subagentVariants = createMemo(() => Object.keys(provider.findModel(subagentModel())?.variants ?? {}))
@@ -190,7 +193,7 @@ const ModelsTab: Component = () => {
inactive={kiloReady()}
>
<Select
options={SPEECH_TO_TEXT_MODEL_OPTIONS}
options={speechOptions()}
current={speechOption()}
value={(item) => item.value}
label={(item) => `${item.label} (${item.provider})`}
@@ -1,5 +1,9 @@
import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model"
import { getSpeechToTextModel } from "../../../../src/speech-to-text/models"
import {
DEFAULT_SPEECH_TO_TEXT_MODEL,
SPEECH_TO_TEXT_MODELS,
type SpeechToTextModelDef,
} from "../../../../src/speech-to-text/models"
type Cfg = {
enabled_providers?: string[]
@@ -21,6 +25,10 @@ export function canUseSpeechToText(cfg: Cfg, auth: Readonly<Record<string, AuthS
return hasSpeechToTextAccess(cfg, auth)
}
export function selectedSpeechToTextModel(cfg: Cfg): string {
return getSpeechToTextModel(cfg.experimental?.speech_to_text_model).id
export function selectedSpeechToTextModel(
cfg: Cfg,
models: readonly SpeechToTextModelDef[] = SPEECH_TO_TEXT_MODELS,
): string {
const id = cfg.experimental?.speech_to_text_model
return models.find((model) => model.id === id)?.id ?? models[0]?.id ?? DEFAULT_SPEECH_TO_TEXT_MODEL.id
}
@@ -1,4 +1,4 @@
import { SPEECH_TO_TEXT_MODELS } from "../../../../src/speech-to-text/models"
import type { SpeechToTextModelDef } from "../../../../src/speech-to-text/models"
export type SpeechToTextModelOption = {
value: string
@@ -6,8 +6,10 @@ export type SpeechToTextModelOption = {
provider: string
}
export const SPEECH_TO_TEXT_MODEL_OPTIONS: SpeechToTextModelOption[] = SPEECH_TO_TEXT_MODELS.map((model) => ({
value: model.id,
label: model.label,
provider: model.provider,
}))
export function speechToTextModelOptions(models: readonly SpeechToTextModelDef[]): SpeechToTextModelOption[] {
return models.map((model) => ({
value: model.id,
label: model.label,
provider: model.provider,
}))
}
@@ -23,6 +23,7 @@ import { NotificationsProvider } from "./notifications"
import { FeedbackProvider } from "./feedback"
import { KiloEmbeddingModelsProvider } from "./kilo-embedding-models"
import { ImageModelsProvider } from "./image-models"
import { SpeechToTextModelsProvider } from "./speech-to-text-models"
import { SpeechToTextPrewarm } from "../components/speech-to-text/SpeechToTextPrewarm"
type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }>
@@ -77,9 +78,11 @@ const Session: ParentComponent = (props) => (
<IndexingProvider>
<KiloEmbeddingModelsProvider>
<ImageModelsProvider>
<NotificationsProvider>
<SessionProvider>{props.children}</SessionProvider>
</NotificationsProvider>
<SpeechToTextModelsProvider>
<NotificationsProvider>
<SessionProvider>{props.children}</SessionProvider>
</NotificationsProvider>
</SpeechToTextModelsProvider>
</ImageModelsProvider>
</KiloEmbeddingModelsProvider>
</IndexingProvider>
@@ -0,0 +1,33 @@
import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentComponent } from "solid-js"
import { SPEECH_TO_TEXT_MODELS, type SpeechToTextModelDef } from "../../../src/speech-to-text/models"
import { useVSCode } from "./vscode"
import type { ExtensionMessage } from "../types/messages"
export type SpeechToTextModelsContextValue = {
models: Accessor<readonly SpeechToTextModelDef[]>
}
export const SpeechToTextModelsContext = createContext<SpeechToTextModelsContextValue>({
models: () => SPEECH_TO_TEXT_MODELS,
})
export const SpeechToTextModelsProvider: ParentComponent = (props) => {
const vscode = useVSCode()
const [models, setModels] = createSignal<readonly SpeechToTextModelDef[]>([...SPEECH_TO_TEXT_MODELS])
const request = () => vscode.postMessage({ type: "requestSpeechToTextModels" })
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "speechToTextModelsLoaded") return
setModels(message.models)
})
request()
const retry = setTimeout(request, 3000)
onCleanup(() => clearTimeout(retry))
onCleanup(unsubscribe)
return <SpeechToTextModelsContext.Provider value={{ models }}>{props.children}</SpeechToTextModelsContext.Provider>
}
export function useSpeechToTextModels(): SpeechToTextModelsContextValue {
return useContext(SpeechToTextModelsContext)
}
@@ -19,6 +19,7 @@ import type { PermissionRequest } from "./permissions"
import type { AnacondaDesktopExtensionMessage } from "../../../../src/shared/anaconda-desktop-messages"
import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions"
import type { ModelSelection, Provider, ProviderAuthState } from "./providers"
import type { SpeechToTextModelDef } from "../../../../src/speech-to-text/models"
import type { AgentInfo, AgentRequirementResult, SkillInfo, SlashCommandInfo } from "./agents"
import type {
BrowserSettings,
@@ -383,6 +384,11 @@ export interface ImageModelsLoadedMessage {
models: Array<{ id: string; name: string; description?: string }>
}
export interface SpeechToTextModelsLoadedMessage {
type: "speechToTextModelsLoaded"
models: SpeechToTextModelDef[]
}
export interface ProvidersLoadedMessage {
type: "providersLoaded"
providers: Record<string, Provider>
@@ -1301,6 +1307,7 @@ export type ExtensionMessage =
| ChatSettingsLoadedMessage
| KiloEmbeddingModelsLoadedMessage
| ImageModelsLoadedMessage
| SpeechToTextModelsLoadedMessage
| ProvidersLoadedMessage
| AgentsLoadedMessage
| SkillsLoadedMessage
@@ -517,6 +517,10 @@ export interface RequestImageModelsMessage {
type: "requestImageModels"
}
export interface RequestSpeechToTextModelsMessage {
type: "requestSpeechToTextModels"
}
export interface OpenSettingsTabRequest {
type: "openSettingsTab"
tab: string
@@ -1582,6 +1586,7 @@ export type WebviewMessage =
| AgentManagerTerminalDestinationSelectedRequest
| AgentManagerTerminalResizeRequest
| RequestImageModelsMessage
| RequestSpeechToTextModelsMessage
// ============================================
// VS Code API type
@@ -218,6 +218,11 @@ export const ImageModel = Schema.Struct({
description: Schema.optional(Schema.String),
})
export const TranscriptionModel = Schema.Struct({
id: Schema.String,
name: Schema.String,
})
const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown)
export const CloudMessage = Schema.StructWithRest(
@@ -272,6 +277,7 @@ export const KiloGatewayPaths = {
edit: `${root}/edit`,
audioTranscriptions: `${root}/audio/transcriptions`,
imageModels: `${root}/models/images`,
transcriptionModels: `${root}/models/transcriptions`,
notifications: `${root}/notifications`,
organization: `${root}/organization`,
clawStatus: `${root}/claw/status`,
@@ -366,6 +372,17 @@ export const KiloGatewayApi = HttpApi.make("kilo")
description: "List image-capable models from the Kilo Gateway OpenRouter passthrough",
}),
),
HttpApiEndpoint.get("transcriptionModels", KiloGatewayPaths.transcriptionModels, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(TranscriptionModel), "Speech-to-text model list"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
}).annotateMerge(
OpenApi.annotations({
identifier: "kilo.models.transcriptions",
summary: "Speech-to-text models",
description: "List transcription-capable models from the Kilo Gateway catalog",
}),
),
HttpApiEndpoint.get("notifications", KiloGatewayPaths.notifications, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Notification), "Notifications list"),
@@ -5,6 +5,7 @@ import {
fetchCloudSession,
fetchCloudSessionForImport,
fetchKiloImageModels,
fetchKiloTranscriptionModels,
getCloudSessions,
getOrganizationId,
getToken,
@@ -614,6 +615,29 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
return result.models
})
const transcriptionModels = Effect.fn("KiloGatewayHttpApi.transcriptionModels")(function* () {
const info = yield* proxyAuth()
if (!info.auth) return yield* Effect.fail(new HttpApiError.Unauthorized({}))
if (!info.token) return yield* Effect.fail(new HttpApiError.Unauthorized({}))
const result = yield* Effect.tryPromise({
try: () =>
fetchKiloTranscriptionModels({
kilocodeToken: info.token,
kilocodeOrganizationId: info.organizationId,
}),
catch: () => new HttpApiError.BadRequest({}),
})
if (result.error) {
const err =
result.error.kind === "unauthorized" ? new HttpApiError.Unauthorized({}) : new HttpApiError.BadRequest({})
return yield* Effect.fail(err)
}
return result.models
})
return handlers
.handle("profile", profile)
.handle("authStatus", authStatus)
@@ -622,6 +646,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
.handle("edit", edit)
.handle("audioTranscriptions", audioTranscriptions)
.handle("imageModels", imageModels)
.handle("transcriptionModels", transcriptionModels)
.handle("notifications", notifications)
.handle("organization", organization)
.handle("clawStatus", clawStatus)
@@ -389,6 +389,7 @@ export const kiloScenarios: Scenario[] = [
.status(401),
http.protected.get("/kilo/notifications", "kilo.notifications").json(200, array),
http.protected.get("/kilo/models/images", "kilo.models.images").probe({ path: "/path" }).status(401),
http.protected.get("/kilo/models/transcriptions", "kilo.models.transcriptions").probe({ path: "/path" }).status(401),
http.protected
.post("/kilo/organization", "kilo.organization.set")
.at((ctx) => ({ path: "/kilo/organization", headers: ctx.headers(), body: { organizationId: null } }))
@@ -237,4 +237,13 @@ describe("Kilo PublicApi OpenAPI contract", () => {
const schema = body?.content?.["application/json"]?.schema
expect(schema?.properties?.prompt).toEqual({ type: "string" })
})
test("documents the transcription model catalog route", () => {
const spec = OpenApi.fromApi(PublicApi)
const route = spec.paths[KiloGatewayPaths.transcriptionModels]?.get
const query = (route?.parameters as Parameter[] | undefined)?.map((item) => item.name)
expect(query).toEqual(["directory", "workspace"])
expect(route?.responses?.["200"]?.content?.["application/json"]?.schema).toMatchObject({ type: "array" })
})
})
+36
View File
@@ -211,6 +211,8 @@ import type {
KiloFimResponses,
KiloModelsImagesErrors,
KiloModelsImagesResponses,
KiloModelsTranscriptionsErrors,
KiloModelsTranscriptionsResponses,
KiloModesErrors,
KiloModesResponses,
KiloNotificationsErrors,
@@ -6855,6 +6857,40 @@ export class Models extends HeyApiClient {
...params,
})
}
/**
* Speech-to-text models
*
* List transcription-capable models from the Kilo Gateway catalog
*/
public transcriptions<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<
KiloModelsTranscriptionsResponses,
KiloModelsTranscriptionsErrors,
ThrowOnError
>({
url: "/kilo/models/transcriptions",
...options,
...params,
})
}
}
export class Organization extends HeyApiClient {
+32
View File
@@ -12154,6 +12154,38 @@ export type KiloModelsImagesResponses = {
export type KiloModelsImagesResponse = KiloModelsImagesResponses[keyof KiloModelsImagesResponses]
export type KiloModelsTranscriptionsData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilo/models/transcriptions"
}
export type KiloModelsTranscriptionsErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type KiloModelsTranscriptionsError = KiloModelsTranscriptionsErrors[keyof KiloModelsTranscriptionsErrors]
export type KiloModelsTranscriptionsResponses = {
/**
* Speech-to-text model list
*/
200: Array<{
id: string
name: string
}>
}
export type KiloModelsTranscriptionsResponse =
KiloModelsTranscriptionsResponses[keyof KiloModelsTranscriptionsResponses]
export type KiloNotificationsData = {
body?: never
path?: never
+75
View File
@@ -13953,6 +13953,81 @@
]
}
},
"/kilo/models/transcriptions": {
"get": {
"tags": ["kilo"],
"operationId": "kilo.models.transcriptions",
"parameters": [
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Speech-to-text model list",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["id", "name"],
"additionalProperties": false
},
"description": "Speech-to-text model list"
}
}
}
},
"400": {
"description": "BadRequest | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
},
{
"$ref": "#/components/schemas/InvalidRequestError"
}
]
}
}
}
}
},
"description": "List transcription-capable models from the Kilo Gateway catalog",
"summary": "Speech-to-text models",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilo.models.transcriptions({\n ...\n})"
}
]
}
},
"/kilo/notifications": {
"get": {
"tags": ["kilo"],