Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ repository = "https://github.com/cppalliance/promptforge"

[workspace.dependencies]
base64 = "0.22"
bytes = "1"
promptforge = { path = "crates/promptforge", version = "0.3.0" }
promptforge-core = { path = "crates/promptforge-core", version = "0.3.0" }
promptforge-core-support = { path = "crates/promptforge-core-support", version = "0.3.0" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ function capabilities(): SettingDef[] {
default: false,
visibleWhen: (ctx) => ctx.value("thinking") !== "never" && ctx.value("thinking") != null,
},
{
key: "voices",
label: "Voices",
help: "The voices the model offers for speech synthesis.",
section: "capabilities",
type: "chips",
default: [],
visibleWhen: (ctx) => ctx.value("kind") === "speech",
},
];
}

Expand Down
39 changes: 37 additions & 2 deletions crates/gateway-config-ui/ui/src/views/model-detail.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ test("the local detail pane renders the registry sections with the model's value
const kindSelect = root.querySelector(".field-row[data-key='kind'] .select");
assert.deepEqual(
dropdownValues(root, "kind"),
["chat", "embedding", "classifier"],
"the header kind dropdown offers the three model kinds",
["chat", "embedding", "classifier", "speech"],
"the header kind dropdown offers the four model kinds",
);
assert.equal(kindSelect.value, "chat");

Expand Down Expand Up @@ -442,6 +442,41 @@ test("Save PUTs the edited payload with untouched secrets redacted, then the pen
);
});

test("picking the speech kind reveals the voices chips and Save PUTs them", async () => {
const stub = fixtureStub();
const { dom, root } = await bootApp({ key: "k", stub });
navigate(dom, "#/remote/gpt-remote");
await settle();

assert.equal(
root.querySelector(".field-row[data-key='voices']"),
null,
"a chat model hides the voices field",
);

dropdownValues(root, "kind");
root.querySelector(".field-row[data-key='kind'] [data-value='speech']").click();
await settle();

const voicesRow = root.querySelector(".field-row[data-key='voices']");
assert.ok(voicesRow, "the speech kind reveals the voices field");
const chips = voicesRow.querySelector(".chip-input input");
chips.value = "nova";
chips.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter" }));
await settle();

root.querySelector(".detail-save").click();
await settle();

const put = stub.calls.find(
(call) => call.url.endsWith("/admin/config") && call.init.method === "PUT",
);
assert.ok(put, "Save PUTs /admin/config");
const model = JSON.parse(put.init.body).model.find((entry) => entry.name === "gpt-remote");
assert.equal(model.kind, "speech", "the picked kind carries into the payload");
assert.deepEqual(model.voices, ["nova"], "the added chip carries into the payload");
});

test("deleting a model confirms, PUTs the config without it, and returns to the list", async () => {
const stub = fixtureStub();
const { dom, root } = await bootApp({ key: "k", stub });
Expand Down
2 changes: 1 addition & 1 deletion crates/gateway-config-ui/ui/src/views/models-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ export function createModelsView(deps: ModelsViewDeps): ModelsView {
help: "The workload this model serves.",
section: "header",
type: "dropdown",
options: ["chat", "embedding", "classifier"],
options: ["chat", "embedding", "classifier", "speech"],
default: "chat",
}),
);
Expand Down
14 changes: 11 additions & 3 deletions crates/gateway-config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,8 +531,8 @@ impl fmt::Display for ToolDialect {
}
}

/// The workload a model serves: chat completions, embeddings, or
/// classification.
/// The workload a model serves: chat completions, embeddings,
/// classification, or speech synthesis.
///
/// The kind scopes which configuration fields are meaningful: chat-only
/// fields (for example `thinking`, `default_max_tokens`,
Expand All @@ -550,6 +550,8 @@ pub enum ModelKind {
Embedding,
/// Classification / reranking.
Classifier,
/// Speech synthesis (`POST /v1/audio/speech`).
Speech,
}

impl fmt::Display for ModelKind {
Expand All @@ -558,6 +560,7 @@ impl fmt::Display for ModelKind {
ModelKind::Chat => "chat",
ModelKind::Embedding => "embedding",
ModelKind::Classifier => "classifier",
ModelKind::Speech => "speech",
};
f.write_str(spelling)
}
Expand All @@ -569,7 +572,8 @@ impl fmt::Display for ModelKind {
/// reaches it. They are flattened into `[[model]]` and `[[local_model]]`,
/// validated at load, and surfaced verbatim on `GET /v1/models` so clients
/// can shape requests before sending them. The effort knobs are chat-only
/// and require a `thinking` mode other than `never`.
/// and require a `thinking` mode other than `never`; the `voices` list is
/// speech-only.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Capabilities {
Expand Down Expand Up @@ -599,6 +603,10 @@ pub struct Capabilities {
/// chat kind only. Defaults to false.
#[serde(default)]
adaptive_thinking: bool,
/// The voices the model offers for speech synthesis; speech kind only.
/// Empty means the model exposes no fixed voice list.
#[serde(default)]
voices: Vec<String>,
}

/// One model name and the backend it resolves to.
Expand Down
43 changes: 41 additions & 2 deletions crates/gateway-config/src/config/accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ impl ModelConfig {
}

/// Returns the workload this model serves: chat (the default),
/// embedding, or classifier.
/// embedding, classifier, or speech.
///
/// # Examples
/// ```
Expand Down Expand Up @@ -1221,7 +1221,7 @@ impl LocalModelConfig {
}

/// Returns the workload this model serves: chat (the default),
/// embedding, or classifier.
/// embedding, classifier, or speech.
///
/// # Examples
/// ```
Expand Down Expand Up @@ -1922,6 +1922,45 @@ impl Capabilities {
pub fn adaptive_thinking(&self) -> bool {
self.adaptive_thinking
}

/// Returns the voices the model offers for speech synthesis (empty when
/// the model exposes no fixed voice list).
///
/// # Examples
/// ```
/// # use gateway_config::Config;
/// # let toml = r#"
/// # config-version = 2
/// # [server]
/// # bind = "127.0.0.1:8080"
/// # api_key = "secret"
/// #
/// # [[endpoint]]
/// # id = "e"
/// # protocol = "openai"
/// # base_url = "http://127.0.0.1:9"
/// # api_key = ""
/// #
/// # [[model]]
/// # name = "m"
/// # kind = "speech"
/// # description = "a model"
/// # context = 8192
/// # upstream = "u"
/// # endpoints = ["e"]
/// # voices = ["alloy", "nova"]
/// # "#;
/// let config = Config::from_toml_str(toml)?;
/// assert_eq!(
/// config.models()[0].capabilities().voices(),
/// ["alloy", "nova"]
/// );
/// # Ok::<(), gateway_config::ConfigError>(())
/// ```
#[must_use]
pub fn voices(&self) -> &[String] {
&self.voices
}
}
impl ToolsConfig {
/// Returns the web-search tool configuration, or `None` when no
Expand Down
2 changes: 2 additions & 0 deletions crates/gateway-config/src/config/tests/serialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ fn enums_round_trip_with_their_toml_spellings() {
check(ModelKind::Chat, "chat");
check(ModelKind::Embedding, "embedding");
check(ModelKind::Classifier, "classifier");
check(ModelKind::Speech, "speech");
check(SttRole::Interim, "interim");
check(SttRole::Final, "final");
}
Expand All @@ -244,6 +245,7 @@ fn capabilities_round_trip_through_json() {
effort_levels: vec!["low".to_owned(), "high".to_owned()],
default_effort: Some("low".to_owned()),
adaptive_thinking: true,
voices: vec!["alloy".to_owned()],
};
let json = serde_json::to_value(&capabilities).expect("serializes");
let back: Capabilities = serde_json::from_value(json).expect("deserializes");
Expand Down
145 changes: 145 additions & 0 deletions crates/gateway-config/src/config/tests/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1863,3 +1863,148 @@ fn rejects_nonchat_model_with_capability_effort_fields() {
}
}
}

#[test]
fn parses_speech_model_with_voices() {
// The speech kind and its voice catalog parse and re-serialize verbatim.
let toml = catalog_with_model_kind("speech", "voices = [\"alloy\", \"nova\"]");
let config = Config::from_toml_str(&toml).unwrap();
let model = &config.models()[0];
assert_eq!(model.kind().to_string(), "speech");
let json = serde_json::to_value(model).expect("serializes");
assert_eq!(json["kind"], "speech");
assert_eq!(json["voices"], serde_json::json!(["alloy", "nova"]));

let toml = catalog_with_local_model_kind("speech", "voices = [\"alloy\"]");
let config = Config::from_toml_str(&toml).unwrap();
let json = serde_json::to_value(&config.local_models()[0]).expect("serializes");
assert_eq!(json["kind"], "speech");
assert_eq!(json["voices"], serde_json::json!(["alloy"]));
}

#[test]
fn accepts_speech_model_with_empty_or_absent_voices() {
// An empty list stays valid; the route skips the voice check for it.
let toml = catalog_with_model_kind("speech", "voices = []");
assert!(Config::parse_toml(&toml).is_ok());
let toml = catalog_with_model_kind("speech", "");
assert!(Config::parse_toml(&toml).is_ok());
let toml = catalog_with_local_model_kind("speech", "");
assert!(Config::parse_toml(&toml).is_ok());
}

#[test]
fn rejects_chat_only_fields_on_speech_models() {
// Speech is a non-chat kind: the chat-only discipline covers it.
for (field, extra) in [
("thinking", "thinking = \"always\""),
("default_max_tokens", "default_max_tokens = 1024"),
("tool_dialect", "tool_dialect = \"gemma3_tool_code\""),
("effort_levels", "effort_levels = [\"low\"]"),
("default_effort", "default_effort = \"low\""),
("adaptive_thinking", "adaptive_thinking = true"),
] {
let toml = catalog_with_model_kind("speech", extra);
match Config::parse_toml(&toml) {
Err(ConfigError::Validation(message)) => {
assert!(
message.contains(field),
"expected the error to name {field}: {message}"
);
}
other => panic!("expected a validation error for {field}, got {other:?}"),
}
}
for (field, extra) in [
("thinking", "thinking = \"always\""),
("chat_template_file", "chat_template_file = \"q.jinja\""),
("effort_levels", "effort_levels = [\"low\"]"),
("adaptive_thinking", "adaptive_thinking = true"),
(
"speculative",
"[local_model.speculative]\ntype = \"draft-mtp\"\nsource = \"/models/d.gguf\"\nsha256 = \"b52f438017efaec5debf1c0d8be690571e212a07c312f1102bbce927258cfc32\"\ndraft_max = 7",
),
(
"multimodal_projector",
"[local_model.multimodal_projector]\nsource = \"/models/p.gguf\"\nsha256 = \"b52f438017efaec5debf1c0d8be690571e212a07c312f1102bbce927258cfc32\"",
),
] {
let toml = catalog_with_local_model_kind("speech", extra);
match Config::parse_toml(&toml) {
Err(ConfigError::Validation(message)) => {
assert!(
message.contains(field),
"expected the error to name {field}: {message}"
);
}
other => panic!("expected a validation error for local {field}, got {other:?}"),
}
}
}

#[test]
fn rejects_voices_on_non_speech_models() {
// `voices` is speech-only, symmetric with the chat-only discipline.
for kind in ["chat", "embedding", "classifier"] {
let toml = catalog_with_model_kind(kind, "voices = [\"alloy\"]");
match Config::parse_toml(&toml) {
Err(ConfigError::Validation(message)) => {
assert!(
message.contains("voices"),
"expected the error to name voices: {message}"
);
}
other => panic!("expected a validation error for kind {kind}, got {other:?}"),
}
let toml = catalog_with_local_model_kind(kind, "voices = [\"alloy\"]");
match Config::parse_toml(&toml) {
Err(ConfigError::Validation(message)) => {
assert!(
message.contains("voices"),
"expected the error to name voices: {message}"
);
}
other => panic!("expected a local validation error for kind {kind}, got {other:?}"),
}
}
}

#[test]
fn rejects_empty_voice_entries() {
for extra in ["voices = [\"\"]", "voices = [\"alloy\", \"\", \"nova\"]"] {
let toml = catalog_with_model_kind("speech", extra);
match Config::parse_toml(&toml) {
Err(ConfigError::Validation(message)) => {
assert!(
message.contains("voices"),
"expected the error to name voices: {message}"
);
}
other => panic!("expected a validation error for {extra:?}, got {other:?}"),
}
let toml = catalog_with_local_model_kind("speech", extra);
assert!(
matches!(Config::parse_toml(&toml), Err(ConfigError::Validation(_))),
"expected local_model {extra:?} to be rejected"
);
}
}

#[test]
fn rejects_duplicate_voices() {
let toml = catalog_with_model_kind("speech", "voices = [\"alloy\", \"nova\", \"alloy\"]");
match Config::parse_toml(&toml) {
Err(ConfigError::Validation(message)) => {
assert!(
message.contains("alloy"),
"expected the error to name the duplicate: {message}"
);
}
other => panic!("expected a validation error, got {other:?}"),
}
let toml = catalog_with_local_model_kind("speech", "voices = [\"alloy\", \"alloy\"]");
assert!(
matches!(Config::parse_toml(&toml), Err(ConfigError::Validation(_))),
"expected local_model duplicate voices to be rejected"
);
}
Loading
Loading