diff --git a/conf/gateway.yaml b/conf/gateway.yaml index 13fdaa2..ede8031 100644 --- a/conf/gateway.yaml +++ b/conf/gateway.yaml @@ -143,9 +143,9 @@ models: protocol: video provider: kling unit_price_micros: 60000 - - name: vidu-video # generic synchronous vendor shape (the video fallback wire) + - name: demo-video # generic synchronous vendor shape (the video fallback wire; no real vendor speaks it) protocol: video - provider: vidu + provider: demo-video - name: grok-imagine-video # async video: request_id + poll, billed per second on done protocol: video provider: xai @@ -164,6 +164,8 @@ models: unit_price_micros: 300000 - name: brave-search # search family protocol: search + provider: brave + unit_price_micros: 5000 - name: hunyuan-lite # e2e: PTU failover (ptu account down -> paygo spillover) protocol: openai-chat provider: tencent @@ -259,8 +261,8 @@ accounts: provider: kling priority: 1 protocols: ["video"] - - name: mock-vidu-1 - provider: vidu + - name: mock-generic-video-1 + provider: demo-video priority: 1 protocols: ["video"] - name: mock-xai-1 # e2e: the xAI host answers video with a request_id + poll diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 65dc93a..620b103 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -30,7 +30,7 @@ pub enum ConfigError { UnknownProvider { model: String, provider: String }, #[error("model `{model}` needs either protocol or provider")] ModelNeedsDispatch { model: String }, - #[error("video model `{model}` must pin a provider (the video wire is provider-specific)")] + #[error("model `{model}` must pin a provider (its wire is provider-specific)")] VideoModelNeedsProvider { model: String }, #[error("duplicate {kind} name `{name}`")] DuplicateName { kind: &'static str, name: String }, @@ -852,12 +852,12 @@ impl GatewayConfig { /// Structural and value invariants checked before use: wire types, prices, /// token-rate weights, retry_status range, quota/variant shape. fn validate(&self) -> Result<(), ConfigError> { - // the video wire is provider-specific (dialect by provider name): an + // video/search wires are provider-specific (dialect by provider name): an // unpinned model would round-robin one vendor's body into another's API if let Some(m) = self .models .iter() - .find(|m| m.protocol == "video" && m.provider.is_none()) + .find(|m| matches!(m.protocol.as_str(), "video" | "search") && m.provider.is_none()) { return Err(ConfigError::VideoModelNeedsProvider { model: m.name.clone(), diff --git a/crates/engines/src/families.rs b/crates/engines/src/families.rs index 42b0bc7..255e816 100644 --- a/crates/engines/src/families.rs +++ b/crates/engines/src/families.rs @@ -3,6 +3,8 @@ //! that boundary. The mock protocol flags byte-level vendor differences as //! deferred to a later fidelity pass. +use std::sync::Arc; + use base64::Engine as _; use gw_models::{GResult, GatewayError, GatewayRequest, GatewayResponse, TypedParams, VideoParams}; use gw_protocol::object; @@ -821,7 +823,7 @@ fn video_outcome(model: &str, v: Value, status: u16) -> EngineOutcome { /// Poll an async video generation where it was submitted, in the account's dialect. pub async fn video_poll( transport: &dyn Transport, - account: &gw_models::Account, + account: &Arc, id: &str, ) -> GResult { let base = account.base_url(VENDOR_SENTINEL); @@ -908,7 +910,7 @@ fn normalize_video_poll(dialect: VideoDialect, status: u16, body: Value) -> Vide /// other dialects answer 404 because their poll carries a URL. pub async fn video_content( transport: &dyn Transport, - account: &gw_models::Account, + account: &Arc, id: &str, poll: &Value, ) -> GResult<(u16, String, bytes::Bytes)> { @@ -970,7 +972,7 @@ fn minimax_content_gap(message: &'static str) -> GatewayError { } fn video_request( - account: &gw_models::Account, + account: &Arc, method: &'static str, url: String, body: Vec, @@ -984,7 +986,7 @@ fn video_request( } fn video_upstream( - account: &gw_models::Account, + account: &Arc, method: &'static str, url: String, headers: Headers, @@ -998,13 +1000,13 @@ fn video_upstream( body, stream: false, account: account.name.clone(), - replay_account: None, + replay_account: Some(Arc::clone(account)), } } async fn video_send( transport: &dyn Transport, - account: &gw_models::Account, + account: &Arc, method: &'static str, url: String, body: Vec, @@ -1019,7 +1021,7 @@ base_engine!(SearchEngine); #[async_trait::async_trait] impl ModelEngine for SearchEngine { - /// Merges the bingsearch/brave/serp/google_custom_search engines. + /// Brave or Google CSE on their providers, else the generic mock shape. async fn run(&mut self) -> GResult { let param = self.base.param()?; let (query, count) = match ¶m.typed { @@ -1027,25 +1029,61 @@ impl ModelEngine for SearchEngine { _ => (self.base.last_message_text(), 3), }; require_non_empty(query, "search query")?; - let body = json!({"query": query, "count": count}); - let (status, v) = self - .base - .round_trip(&self.base.vendor_url("search"), body) - .await?; - let titles: Vec = v["results"] - .as_array() + let q = percent_encoding::utf8_percent_encode(query, percent_encoding::NON_ALPHANUMERIC); + let (status, v, results) = match self.base.provider() { + "brave" => { + let url = format!( + "{}/res/v1/web/search?q={q}&count={count}", + self.base.base_url(VENDOR_SENTINEL) + ); + let headers = vec![ + ("accept", "application/json".into()), + ("x-subscription-token", self.base.api_key()), + ]; + let (status, v) = self.search_get(url, headers).await?; + (status, v, "/web/results") + } + _ => { + let body = json!({"query": query, "count": count}); + let (status, v) = self + .base + .round_trip(&self.base.vendor_url("search"), body) + .await?; + (status, v, "/results") + } + }; + let titles: Vec = v + .pointer(results) + .and_then(Value::as_array) .map(|rs| { rs.iter() .filter_map(|r| r["title"].as_str().map(str::to_owned)) .collect() }) .unwrap_or_default(); - Ok(family_outcome( - titles.join("; "), - ¶m.model_name, - v, - status, - )) + let mut out = family_outcome(titles.join("; "), ¶m.model_name, v, status); + out.response.billed_units = 1; + Ok(out) + } +} + +impl SearchEngine { + async fn search_get(&self, url: String, headers: Headers) -> GResult<(u16, Value)> { + let reply = self + .base + .transport + .send(UpstreamRequest { + protocol: gw_consts::Protocol::Search, + method: "GET", + url, + headers, + body: Vec::new(), + stream: false, + account: self.base.account(), + replay_account: self.base.replay_account(), + }) + .await?; + parse_json_reply(reply) } } @@ -2144,7 +2182,7 @@ mod tests { assert_eq!(submit.http_status, 502); assert!(submit.message.contains("minimax base_resp 1008")); - let poll = match video_poll(transport.as_ref(), account.as_ref(), "task-1").await { + let poll = match video_poll(transport.as_ref(), &account, "task-1").await { Ok(_) => panic!("MiniMax business error accepted as a successful poll"), Err(e) => e, }; diff --git a/crates/engines/src/http_transport.rs b/crates/engines/src/http_transport.rs index c289cf2..1ab02d8 100644 --- a/crates/engines/src/http_transport.rs +++ b/crates/engines/src/http_transport.rs @@ -273,22 +273,35 @@ impl Transport for DispatchTransport { } } -/// The vendor's `Retry-After` seconds, capped; unparseable waits at least a -/// second, absent falls back to the connect path's linear backoff. +/// The vendor's retry delay, capped; standard `Retry-After` takes precedence, +/// then seconds-until-reset, else the connect path's linear backoff. fn status_backoff(headers: &reqwest::header::HeaderMap, attempt: u32) -> Duration { const MAX_RETRY_AFTER: Duration = Duration::from_secs(30); const MIN_HEADER_WAIT: Duration = Duration::from_secs(1); - match headers + if let Some(v) = headers .get(reqwest::header::RETRY_AFTER) .and_then(|v| v.to_str().ok()) { - Some(v) => v + return v .trim() .parse::() .map(|secs| Duration::from_secs(secs).min(MAX_RETRY_AFTER)) - .unwrap_or_else(|_| (RETRY_BACKOFF * attempt).max(MIN_HEADER_WAIT)), - None => RETRY_BACKOFF * attempt, + .unwrap_or_else(|_| (RETRY_BACKOFF * attempt).max(MIN_HEADER_WAIT)); } + headers + .get("x-ratelimit-reset") + .and_then(|v| v.to_str().ok()) + .and_then(|v| { + v.split(',') + .filter_map(|secs| secs.trim().parse::().ok()) + .min() + }) + .map(|secs| { + Duration::from_secs(secs) + .max(MIN_HEADER_WAIT) + .min(MAX_RETRY_AFTER) + }) + .unwrap_or(RETRY_BACKOFF * attempt) } /// A live SSE byte stream that yields one terminal error when no chunk arrives @@ -473,5 +486,8 @@ mod tests { Duration::from_secs(1), "an unparsed header still waits at least a second" ); + h.remove(reqwest::header::RETRY_AFTER); + h.insert("x-ratelimit-reset", "1, 1419704".parse().unwrap()); + assert_eq!(status_backoff(&h, 1), Duration::from_secs(1)); } } diff --git a/crates/engines/src/transport.rs b/crates/engines/src/transport.rs index 7a650d3..b41c7f9 100644 --- a/crates/engines/src/transport.rs +++ b/crates/engines/src/transport.rs @@ -878,6 +878,35 @@ impl MockTransport { } fn search_reply(&self, req: &UpstreamRequest) -> GResult { + if req.url.contains("/res/v1/web/search") { + let params = req.url.split_once('?').map_or("", |(_, params)| params); + let param = |name| { + params.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == name).then_some(value) + }) + }; + let q = param("q") + .map(|q| { + percent_encoding::percent_decode_str(q) + .decode_utf8_lossy() + .into_owned() + }) + .unwrap_or_default(); + let count = param("count") + .and_then(|count| count.parse::().ok()) + .unwrap_or(3) + .clamp(1, 20); + let results: Vec = (0..count) + .map(|i| { + json!({"title": format!("brave result {} for {q}", i + 1), + "url": format!("mock://brave/{}", i + 1), + "description": format!("[mock-brave] about {q}")}) + }) + .collect(); + return Self::ok_json(json!({"query": {"original": q}, + "web": {"results": results}})); + } let body = Self::parse(&req.body, "search")?; let q = body["query"].as_str().unwrap_or(""); let n = body["count"].as_i64().unwrap_or(3).clamp(1, 10); @@ -994,6 +1023,9 @@ impl Transport for MockTransport { if req.protocol == Protocol::Video { return self.video_reply(&req); } + if req.protocol == Protocol::Search { + return self.search_reply(&req); + } let u = req.url.as_str(); if u.contains("/model/") { self.bedrock_reply(&req) @@ -1021,8 +1053,6 @@ impl Transport for MockTransport { self.moderations_reply(&req) } else if u.contains("/rerank") { self.rerank_reply(&req) - } else if u.contains("/search") { - self.search_reply(&req) } else if u.contains("/responses") { self.responses_reply(&req) } else if u.contains("/v1/completions") { diff --git a/crates/engines/tests/http_transport_wire.rs b/crates/engines/tests/http_transport_wire.rs index 044f430..1bf9790 100644 --- a/crates/engines/tests/http_transport_wire.rs +++ b/crates/engines/tests/http_transport_wire.rs @@ -321,10 +321,13 @@ async fn per_account_policy_and_connect_retry() { "an account dropped from the reload falls back to the default" ); - let closed = { + // re-draw if another process steals the freed port before we dial it (CI runners do) + let closed = std::iter::repeat_with(|| { let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); l.local_addr().unwrap() - }; + }) + .find(|addr| std::net::TcpStream::connect_timeout(addr, Duration::from_millis(50)).is_err()) + .unwrap(); let started = std::time::Instant::now(); let err = transport .send(UpstreamRequest { diff --git a/crates/engines/tests/request_construction.rs b/crates/engines/tests/request_construction.rs index 51d536e..50d77f4 100644 --- a/crates/engines/tests/request_construction.rs +++ b/crates/engines/tests/request_construction.rs @@ -855,7 +855,7 @@ async fn video_request_shape() { ); let req = typed_req( Protocol::Video, - "vidu-video", + "demo-video", TypedParams::Video(VideoParams { prompt: "a dog surfing".into(), duration_seconds: Some(5), @@ -866,7 +866,7 @@ async fn video_request_shape() { ); let _ = VideoEngine::new(req, t.clone()).run().await.unwrap(); let b = t.body_json(); - assert_eq!(b["model"], "vidu-video"); + assert_eq!(b["model"], "demo-video"); assert_eq!(b["prompt"], "a dog surfing"); assert_eq!(b["duration"], 5); assert_eq!(b["resolution"], "1080p"); diff --git a/crates/models/src/params.rs b/crates/models/src/params.rs index e48386b..66c5720 100644 --- a/crates/models/src/params.rs +++ b/crates/models/src/params.rs @@ -19,9 +19,9 @@ pub enum TypedParams { AudioTts(TtsParams), /// whisper / azure / google / ... (speech-to-text) AudioStt(SttParams), - /// sora / veo / kling / runway / vidu / minimax (video generation) + /// Video generation (Sora / Wan / DashScope / Hailuo / Kling dialects + a generic shape). Video(VideoParams), - /// bing / brave / serp / google custom search + /// Web search (Brave dialect + a generic shape). Search(SearchParams), /// content moderation (openai moderations shape) Moderation(ModerationParams), diff --git a/crates/server/tests/e2e.rs b/crates/server/tests/e2e.rs index 246f45f..ce4f3da 100644 --- a/crates/server/tests/e2e.rs +++ b/crates/server/tests/e2e.rs @@ -1458,7 +1458,7 @@ async fn async_video_bills_once_on_the_first_done_poll() { .oneshot(post( "/v1/videos/generations", Some("ak-demo-123"), - r#"{"model":"vidu-video","prompt":"a dog surfing"}"#, + r#"{"model":"demo-video","prompt":"a dog surfing"}"#, )) .await .unwrap(); @@ -1771,6 +1771,34 @@ async fn sora_content_settles_once_without_a_prior_status_poll() { assert_eq!(settled["request_id"].as_str().unwrap(), id); } +#[tokio::test] +async fn search_routes_brave_and_bills_one_unit() { + let app = app(); + let resp = app + .clone() + .oneshot(post( + "/v1/search", + Some("ak-demo-123"), + r#"{"model":"brave-search","query":"api gateway","count":1}"#, + )) + .await + .unwrap(); + let status = resp.status(); + let j = body_json(resp).await; + assert_eq!(status, StatusCode::OK, "{j}"); + assert_eq!(j["query"]["original"], "api gateway", "{j}"); + assert_eq!(j["web"]["results"].as_array().map(Vec::len), Some(1), "{j}"); + let j = body_json(app.oneshot(internal_get("/internal/ledger")).await.unwrap()).await; + let row = j["records"] + .as_array() + .unwrap() + .iter() + .find(|r| r["served_model"] == "brave-search") + .unwrap(); + assert_eq!(row["billed_units"], 1); + assert_eq!(row["cost_micros"], 5000); +} + #[tokio::test] async fn unit_priced_surfaces_bill_characters_and_seconds() { let app = app(); diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index be185ba..0d215da 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -404,11 +404,11 @@ impl AccountPool { self.select_excluding(p, provider, &[]) } - pub fn named(&self, name: &str) -> Option<&Account> { + pub fn named(&self, name: &str) -> Option> { self.accounts .iter() .find(|a| a.name == name) - .map(Arc::as_ref) + .map(Arc::clone) } /// [`Self::select_excluding`] plus a health filter: cooldown accounts are diff --git a/crates/views/src/lib.rs b/crates/views/src/lib.rs index c329637..cfccbbc 100644 --- a/crates/views/src/lib.rs +++ b/crates/views/src/lib.rs @@ -126,6 +126,7 @@ pub fn app(state: AppState) -> Router { .route("/v1/audio/transcriptions", post(audio_transcriptions)) .route("/v1/audio/translations", post(audio_translations)) .route("/v1/moderations", post(moderations)) + .route("/v1/search", post(search)) .route("/v1/rerank", post(rerank)) .route("/v1/batches", post(batches_submit)) .route("/v1/batches/{id}", get(batches_get)) @@ -4030,7 +4031,7 @@ async fn admit_video_job( async fn poll_and_settle_video( s: &AppState, job: &VideoJob, - account: &gw_models::Account, + account: &Arc, ) -> Result { let state = s.handler.state(); let cfg = s.handler.cfg(); @@ -4102,7 +4103,7 @@ async fn videos_get( format!("account {} is no longer configured", job.account), ); }; - let poll = match poll_and_settle_video(&s, &job, account).await { + let poll = match poll_and_settle_video(&s, &job, &account).await { Ok(poll) => poll, Err(resp) => return resp, }; @@ -4130,7 +4131,7 @@ async fn videos_content( format!("account {} is no longer configured", job.account), ); }; - let poll = match poll_and_settle_video(&s, &job, account).await { + let poll = match poll_and_settle_video(&s, &job, &account).await { Ok(poll) => poll, Err(resp) => return resp, }; @@ -4139,7 +4140,7 @@ async fn videos_content( } match gw_engines::families::video_content( s.handler.transport.as_ref(), - account, + &account, &job.id, &poll.body, ) @@ -4319,6 +4320,37 @@ async fn moderations( .await } +/// POST /v1/search — web search as a routed backend: `{model, query, count?}`. +async fn search( + State(s): State, + headers: HeaderMap, + Authed(ak): Authed, + ApiJson(mut body): ApiJson, +) -> Response { + let started = Instant::now(); + let model = gw_engines::engine::take_string(&mut body, "/model").unwrap_or_default(); + let query = gw_engines::engine::take_string(&mut body, "/query").unwrap_or_default(); + if model.is_empty() || query.is_empty() { + return error_response(400, "model and query are required"); + } + let typed = TypedParams::Search(gw_models::SearchParams { + query, + count: body["count"].as_i64().unwrap_or(3).clamp(1, 20), + }); + family_response( + &s, + ak, + model, + gw_consts::Protocol::Search, + typed, + user_hint(&headers, &body["user"]), + "search", + "search", + started, + ) + .await +} + /// POST /v1/rerank — Cohere/Jina-compatible: `{model, query, documents, top_n?}`. async fn rerank( State(s): State, diff --git a/docs/api.md b/docs/api.md index e9ffbe4..b41d1f0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -62,6 +62,12 @@ user. See [Governance](governance.md#per-user-attribution-and-billing). |--------|------|-------| | POST | `/v1/rerank` | Cohere/Jina-compatible: `{model, query, documents, top_n?}` → `{results: [{index, relevance_score}]}` | +## Search + +| Method | Path | Notes | +|--------|------|-------| +| POST | `/v1/search` | web search as a routed backend: `{model, query, count?}`; a `brave` provider speaks the Brave Search API (the vendor body passes through), each search bills one unit at the model's `unit_price_micros` | + ### Chat completions ```bash diff --git a/docs/providers.md b/docs/providers.md index 9ff1369..6cddcf2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -80,9 +80,14 @@ usage); the rest are marked non-streaming below and always answer buffered: | `aws-llama` | Meta Llama on AWS Bedrock | `https://bedrock-runtime..amazonaws.com` | SigV4 (see below); model name = the Bedrock model id or inference profile (`meta.llama3-8b-instruct-v1:0`, `us.meta.llama3-3-70b-instruct-v1:0`, `us.meta.llama4-scout-17b-instruct-v1:0`); the conversation is rendered into the Llama 3 (or Llama 4) chat template; usage from the token-count headers / invocation metrics, else the body counts | | `minimax-v1` | MiniMax legacy v1 (`abab*`) | `https://api.minimax.chat` | `Bearer` (non-streaming); kept for existing accounts — the vendor has retired it for new ones; new integrations should use MiniMax's OpenAI-/Anthropic-compatible endpoints | -The factory also dispatches `video`, `search`, generic `audio`, and -`passthrough` protocols (kling-v1-6, grok-imagine-video, sora-2 and -brave-search ship example accounts in the default config). `protocol: video` +`protocol: search` routes web search: a `brave` provider account speaks the +Brave Search API (`X-Subscription-Token`, live-verified; one unit per query), +anything else the generic mock shape. Google's Custom Search JSON API is +deliberately not wired — Google closed it to new customers (existing projects +keep access until 2027-01-01), so no reachable configuration exists. The factory also dispatches `video`, +generic `audio`, and `passthrough` protocols (kling-v1-6, grok-imagine-video, +sora-2 and brave-search ship example accounts in the default config). +`protocol: video` picks its wire from the account's provider name — `openai`/`azure` → Sora, `siliconflow` → Wan submit/status, `alibaba`/`dashscope` → the DashScope task API (the account endpoint is the bare `https://dashscope-intl.aliyuncs.com`; diff --git a/scripts/live-matrix/live.yaml b/scripts/live-matrix/live.yaml index 9646b19..f7cf5bc 100644 --- a/scripts/live-matrix/live.yaml +++ b/scripts/live-matrix/live.yaml @@ -87,6 +87,8 @@ models: # local ollama through the generic openai-compatible path - {name: "qwen2.5:0.5b", provider: ollama, input_price_per_1k_micros: 100, output_price_per_1k_micros: 100} - {name: grok-voice-latest, provider: xai, protocol: realtime, input_price_per_1k_micros: 1000, output_price_per_1k_micros: 4000} + # web search + - {name: brave-search, provider: brave, protocol: search, unit_price_micros: 5000} # rerank vendors - {name: rerank-v3.5, protocol: rerank, provider: cohere, unit_price_micros: 2000} - {name: jina-reranker-v3, protocol: rerank, provider: jina, input_price_per_1k_micros: 20} @@ -103,6 +105,11 @@ accounts: endpoint: https://api-singapore.klingai.com api_key_env: KLING_API_KEY protocols: ["video"] + - name: brave + provider: brave + endpoint: https://api.search.brave.com + api_key_env: BRAVE_API_KEY + protocols: ["search"] - {name: cohere, provider: cohere, endpoint: https://api.cohere.com, api_key_env: COHERE_API_KEY, protocols: ["rerank"], cost_unit_price_micros: 1000} - {name: jina, provider: jina, endpoint: https://api.jina.ai, api_key_env: JINA_API_KEY, protocols: ["rerank"]} - {name: bedrock, provider: aws, endpoint: https://bedrock-runtime.us-east-1.amazonaws.com, api_key_env: AWS_BEARER_TOKEN_BEDROCK, protocols: ["aws-anthropic", "aws-converse", "aws-llama"], timeout_seconds: 120} diff --git a/scripts/live-matrix/live_matrix.py b/scripts/live-matrix/live_matrix.py index a10c160..11cd356 100644 --- a/scripts/live-matrix/live_matrix.py +++ b/scripts/live-matrix/live_matrix.py @@ -39,6 +39,7 @@ "bedrock", "xai", "video", + "search", "openai-rt", "ollama", ] @@ -454,6 +455,26 @@ def case_video( record(name, ok, detail) +def case_search(gw: Gateway, model: str) -> None: + """One web search bills one unit at the model's unit price; results pass through.""" + name = f"{model} search" + before, _ = gw.ledger() + st, txt = gw.call("/v1/search", {"model": model, "query": "what is an api gateway", "count": 3}) + if st != 200: + record(name, False, f"HTTP {st}: {txt[:200]}") + return + j = json.loads(txt) + results = (j.get("web") or {}).get("results") or j.get("results") or [] + count, row = gw.ledger() + ok = ( + count == before + 1 + and len(results) > 0 + and row["billed_units"] == 1 + and row["cost_micros"] == MODELS[model]["unit"] + ) + record(name, ok, f"results={len(results)} ledger units/cost={row['billed_units']}/{row['cost_micros']}") + + def case_realtime(gw: Gateway, model: str) -> None: """One gated realtime turn with audio output: the vendor's usage frame settles the ledger row (not the estimate).""" name = f"{model} realtime" @@ -971,6 +992,8 @@ def run_group(gw: Gateway, group: str) -> None: case_video(gw, "wan2.2-t2v-plus", {}, done="SUCCEEDED", units=5) case_video(gw, "MiniMax-Hailuo-02", {"duration": 6}, done="Success", units=1, content=True) case_video(gw, "kling-v1-6", {"duration": 5, "aspect_ratio": "16:9"}, done="succeed") + elif group == "search": + case_search(gw, "brave-search") elif group == "openai-rt": case_realtime(gw, "gpt-realtime-mini") elif group == "ollama":