Skip to content

Commit 8fef217

Browse files
haitrroxyhelper[bot]
authored andcommitted
fix: azure openai not working with new agentic workflow (#2189)
* fix: azure openai not working with new agentic workflow * fix: resuming * fix: warning GitOrigin-RevId: 9124657a9da1cbbc4800c3f6e3493fd38036f090
1 parent 64abc37 commit 8fef217

7 files changed

Lines changed: 251 additions & 48 deletions

File tree

crates/agentic/analytics/src/config/mod.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,12 @@ pub struct ResolvedModelInfo {
226226
/// Used to decide vendor precedence: when a ref is set the ref's vendor
227227
/// is preferred even if `llm.model` is also explicitly overridden.
228228
pub is_explicit_ref: bool,
229+
/// Azure deployment ID (e.g. `"my-gpt4o-deployment"`). Present only for
230+
/// Azure OpenAI models configured with `azure_deployment_id` in config.yml.
231+
pub azure_deployment_id: Option<String>,
232+
/// Azure API version (e.g. `"2025-03-01-preview"`). Present only for
233+
/// Azure OpenAI models configured with `azure_api_version` in config.yml.
234+
pub azure_api_version: Option<String>,
229235
}
230236

231237
// ── BuildContext ──────────────────────────────────────────────────────────────
@@ -286,12 +292,40 @@ fn build_engine(cfg: &SemanticEngineConfig) -> Result<Box<dyn SemanticEngine>, C
286292
///
287293
/// Extracted so it can be called both for the global client and for per-state
288294
/// model overrides (which inherit vendor, key, and base_url).
295+
///
296+
/// When `azure_deployment_id` and `azure_api_version` are both `Some`, the
297+
/// model is Azure OpenAI: `OpenAiCompatProvider` is used with the full Azure
298+
/// Chat Completions URL regardless of `vendor`.
289299
fn build_llm_client(
290300
vendor: &LlmVendor,
291301
api_key: &str,
292302
model: &str,
293303
base_url: Option<&str>,
304+
azure_deployment_id: Option<&str>,
305+
azure_api_version: Option<&str>,
294306
) -> LlmClient {
307+
if let (Some(deployment_id), Some(api_version), Some(base)) =
308+
(azure_deployment_id, azure_api_version, base_url)
309+
{
310+
return LlmClient::with_provider(OpenAiCompatProvider::for_azure(
311+
api_key,
312+
model,
313+
base,
314+
deployment_id,
315+
api_version,
316+
));
317+
}
318+
if azure_deployment_id.is_some() && azure_api_version.is_some() && base_url.is_none() {
319+
tracing::warn!(
320+
"Azure config has deployment_id and api_version set but no base_url; \
321+
falling back to standard OpenAI."
322+
);
323+
} else if azure_deployment_id.is_some() != azure_api_version.is_some() {
324+
tracing::warn!(
325+
"Azure config is incomplete: both azure_deployment_id and azure_api_version must \
326+
be set together. Falling back to standard OpenAI."
327+
);
328+
}
295329
match vendor {
296330
LlmVendor::Anthropic => LlmClient::with_model(api_key, model),
297331
LlmVendor::OpenAi => {
@@ -489,10 +523,21 @@ impl AgentConfig {
489523
.as_deref()
490524
.or(pmi.as_ref().and_then(|m| m.base_url.as_deref()));
491525

492-
let client = build_llm_client(effective_vendor, &api_key, &model, effective_base_url);
526+
// Azure fields from the project model config (not overridable per-state).
527+
let azure_deployment_id = pmi.as_ref().and_then(|m| m.azure_deployment_id.as_deref());
528+
let azure_api_version = pmi.as_ref().and_then(|m| m.azure_api_version.as_deref());
529+
530+
let client = build_llm_client(
531+
effective_vendor,
532+
&api_key,
533+
&model,
534+
effective_base_url,
535+
azure_deployment_id,
536+
azure_api_version,
537+
);
493538

494539
// Build per-state clients for states that declare a `model:` override.
495-
// Inherits vendor / api_key / base_url from the global config.
540+
// Inherits vendor / api_key / base_url / azure config from the global config.
496541
let state_clients: std::collections::HashMap<String, LlmClient> = self
497542
.states
498543
.iter()
@@ -503,6 +548,8 @@ impl AgentConfig {
503548
&api_key,
504549
state_model,
505550
effective_base_url,
551+
azure_deployment_id,
552+
azure_api_version,
506553
);
507554
(state_name.clone(), c)
508555
})
@@ -559,6 +606,8 @@ impl AgentConfig {
559606
&api_key,
560607
override_model,
561608
effective_base_url,
609+
azure_deployment_id,
610+
azure_api_version,
562611
);
563612
solver = solver.with_client_override(override_client);
564613
}

crates/agentic/llm/src/openai_compat.rs

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ fn inject_cot(system: &str) -> String {
155155
// ── OpenAiCompatProvider ──────────────────────────────────────────────────────
156156

157157
/// OpenAI Chat Completions API provider for OpenAI-compatible backends
158-
/// (Ollama, vLLM, LM Studio, etc.).
158+
/// (Ollama, vLLM, LM Studio, Azure OpenAI, etc.).
159159
///
160160
/// Uses the `/v1/chat/completions` endpoint which is the de-facto standard for
161161
/// locally-hosted LLMs. Supports:
@@ -170,9 +170,8 @@ fn inject_cot(system: &str) -> String {
170170
pub struct OpenAiCompatProvider {
171171
api_key: String,
172172
model: String,
173-
/// Base URL of the Chat Completions endpoint, e.g.
174-
/// `http://localhost:11434/v1` (Ollama) or `http://host:8000/v1` (vLLM).
175-
base_url: String,
173+
/// Full Chat Completions URL used for every request.
174+
completions_url: String,
176175
client: reqwest::Client,
177176
}
178177

@@ -188,20 +187,58 @@ impl OpenAiCompatProvider {
188187
base_url: impl Into<String>,
189188
) -> Self {
190189
let mut base = base_url.into();
191-
// Normalise: strip trailing slash.
192190
while base.ends_with('/') {
193191
base.pop();
194192
}
195193
Self {
196194
api_key: api_key.into(),
197195
model: model.into(),
198-
base_url: base,
196+
completions_url: format!("{base}/chat/completions"),
199197
client: reqwest::Client::new(),
200198
}
201199
}
202200

203-
fn completions_url(&self) -> String {
204-
format!("{}/chat/completions", self.base_url)
201+
/// Create a provider with an explicit full completions URL.
202+
///
203+
/// Use this when the target endpoint cannot be expressed as `{base}/chat/completions`,
204+
/// for example Azure OpenAI which requires a deployment path and `api-version` query
205+
/// parameter: `https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={ver}`.
206+
pub fn with_completions_url(
207+
api_key: impl Into<String>,
208+
model: impl Into<String>,
209+
completions_url: impl Into<String>,
210+
) -> Self {
211+
Self {
212+
api_key: api_key.into(),
213+
model: model.into(),
214+
completions_url: completions_url.into(),
215+
client: reqwest::Client::new(),
216+
}
217+
}
218+
219+
/// Create a provider targeting an Azure OpenAI deployment.
220+
///
221+
/// Constructs the full Chat Completions URL from the resource endpoint,
222+
/// deployment name, and API version. Trailing slashes on `base_url` are
223+
/// normalised automatically.
224+
pub fn for_azure(
225+
api_key: impl Into<String>,
226+
model: impl Into<String>,
227+
base_url: impl Into<String>,
228+
deployment_id: impl AsRef<str>,
229+
api_version: impl AsRef<str>,
230+
) -> Self {
231+
let mut base = base_url.into();
232+
while base.ends_with('/') {
233+
base.pop();
234+
}
235+
let url = format!(
236+
"{}/openai/deployments/{}/chat/completions?api-version={}",
237+
base,
238+
deployment_id.as_ref(),
239+
api_version.as_ref()
240+
);
241+
Self::with_completions_url(api_key, model, url)
205242
}
206243
}
207244

@@ -301,10 +338,10 @@ impl LlmProvider for OpenAiCompatProvider {
301338
}
302339
}
303340

304-
let url = self.completions_url();
341+
let url = &self.completions_url;
305342
let mut req = self
306343
.client
307-
.post(&url)
344+
.post(url.as_str())
308345
.header("content-type", "application/json");
309346

310347
if !self.api_key.is_empty() {
@@ -548,3 +585,47 @@ impl LlmProvider for OpenAiCompatProvider {
548585
&self.model
549586
}
550587
}
588+
589+
#[cfg(test)]
590+
mod tests {
591+
use super::*;
592+
593+
#[test]
594+
fn for_azure_builds_correct_url() {
595+
let p = OpenAiCompatProvider::for_azure(
596+
"key",
597+
"gpt-4",
598+
"https://myresource.openai.azure.com",
599+
"my-deployment",
600+
"2024-05-01-preview",
601+
);
602+
assert_eq!(
603+
p.completions_url,
604+
"https://myresource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-05-01-preview"
605+
);
606+
}
607+
608+
#[test]
609+
fn for_azure_strips_trailing_slashes() {
610+
let p = OpenAiCompatProvider::for_azure(
611+
"key",
612+
"gpt-4",
613+
"https://myresource.openai.azure.com///",
614+
"dep",
615+
"2024-02-01",
616+
);
617+
assert_eq!(
618+
p.completions_url,
619+
"https://myresource.openai.azure.com/openai/deployments/dep/chat/completions?api-version=2024-02-01"
620+
);
621+
}
622+
623+
#[test]
624+
fn new_strips_trailing_slashes() {
625+
let p = OpenAiCompatProvider::new("key", "model", "http://localhost:11434/v1//");
626+
assert_eq!(
627+
p.completions_url,
628+
"http://localhost:11434/v1/chat/completions"
629+
);
630+
}
631+
}

crates/agentic/pipeline/src/lib.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,10 @@ impl PipelineBuilder {
586586
// created the run via insert_run_with_parent.
587587
let source_type = "builder";
588588
if !skip_db_insert {
589-
let metadata = serde_json::json!({ "agent_id": "__builder__" });
589+
let metadata = serde_json::json!({
590+
"agent_id": "__builder__",
591+
"model": model,
592+
});
590593
agentic_runtime::crud::insert_run(
591594
db,
592595
run_id,
@@ -656,18 +659,27 @@ impl PipelineBuilder {
656659

657660
/// Resolve the builder domain's LLM client via the platform port.
658661
///
659-
/// Preserves the legacy fallback: if no model config matches, default to
660-
/// `claude-sonnet-4-6` with the key read from `ANTHROPIC_API_KEY`.
662+
/// Tries the explicit model ref first, then the project's configured default.
663+
/// Never falls back to a hardcoded provider.
661664
async fn build_builder_llm_client(ctx: &dyn ProjectContext, model: Option<String>) -> LlmClient {
662-
let model_name = model.unwrap_or_else(|| "claude-sonnet-4-6".to_string());
663-
if let Some(info) = ctx.resolve_model(Some(&model_name), false).await {
665+
// Try explicit model ref, then project default.
666+
let info = if let Some(ref name) = model {
667+
match ctx.resolve_model(Some(name), false).await {
668+
Some(info) => Some(info),
669+
None => ctx.resolve_model(None, false).await,
670+
}
671+
} else {
672+
ctx.resolve_model(None, false).await
673+
};
674+
if let Some(info) = info {
664675
return platform::build_llm_client(&info);
665676
}
666-
let api_key = ctx
667-
.resolve_secret("ANTHROPIC_API_KEY")
668-
.await
669-
.unwrap_or_default();
670-
LlmClient::with_model(api_key, model_name)
677+
tracing::warn!(
678+
model = ?model,
679+
"builder: no LLM model resolved from project config; LLM calls will fail"
680+
);
681+
// Return a placeholder — the LLM call will fail with a clear error.
682+
LlmClient::with_model("", model.unwrap_or_default())
671683
}
672684

673685
// ── StartedPipeline (type-erased) ───────────────────────────────────────────

crates/agentic/pipeline/src/platform/mod.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,39 @@ pub async fn resolve_connectors(
116116
}
117117

118118
/// Build an [`LlmClient`] from a [`ResolvedModelInfo`], dispatching on vendor.
119+
///
120+
/// Azure OpenAI models are detected via `azure_deployment_id` / `azure_api_version`
121+
/// and routed to [`OpenAiCompatProvider`] (Chat Completions) with the correct
122+
/// deployment URL, bypassing the Responses API used by [`OpenAiProvider`].
119123
pub fn build_llm_client(info: &ResolvedModelInfo) -> LlmClient {
120124
let api_key = info.api_key.as_deref().unwrap_or("");
125+
if let (Some(deployment_id), Some(api_version), Some(base_url)) = (
126+
info.azure_deployment_id.as_deref(),
127+
info.azure_api_version.as_deref(),
128+
info.base_url.as_deref(),
129+
) {
130+
return LlmClient::with_provider(OpenAiCompatProvider::for_azure(
131+
api_key,
132+
&info.model,
133+
base_url,
134+
deployment_id,
135+
api_version,
136+
));
137+
}
138+
if info.azure_deployment_id.is_some()
139+
&& info.azure_api_version.is_some()
140+
&& info.base_url.is_none()
141+
{
142+
tracing::warn!(
143+
"Azure config has deployment_id and api_version set but no base_url; \
144+
falling back to standard OpenAI."
145+
);
146+
} else if info.azure_deployment_id.is_some() != info.azure_api_version.is_some() {
147+
tracing::warn!(
148+
"Azure config is incomplete: both azure_deployment_id and azure_api_version must \
149+
be set together. Falling back to standard OpenAI."
150+
);
151+
}
121152
match &info.vendor {
122153
LlmVendor::Anthropic => LlmClient::with_model(api_key, &info.model),
123154
LlmVendor::OpenAi => {

crates/app/src/agentic_wiring/project_ctx.rs

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -605,22 +605,39 @@ async fn resolve_model_impl(
605605
let model_name = model.model_name().to_string();
606606
let key_var = model.key_var().map(|s| s.to_string());
607607

608-
let (vendor, base_url, extra_api_key) = match model {
609-
Model::Anthropic { config: m } => (LlmVendor::Anthropic, m.api_url.clone(), None),
610-
Model::OpenAI { config: m } => (LlmVendor::OpenAi, m.api_url.clone(), None),
611-
Model::Ollama { config: m } => (
612-
LlmVendor::OpenAiCompat,
613-
Some(m.api_url.clone()),
614-
Some(m.api_key.clone()),
615-
),
616-
Model::Google { .. } => {
617-
tracing::warn!(
618-
model = name,
619-
"Google/Gemini models are not yet supported in analytics agents"
620-
);
621-
return None;
622-
}
623-
};
608+
let (vendor, base_url, extra_api_key, azure_deployment_id, azure_api_version) =
609+
match model {
610+
Model::Anthropic { config: m } => {
611+
(LlmVendor::Anthropic, m.api_url.clone(), None, None, None)
612+
}
613+
Model::OpenAI { config: m } => {
614+
let (dep_id, api_ver) = m
615+
.azure
616+
.as_ref()
617+
.map(|a| {
618+
(
619+
Some(a.azure_deployment_id.clone()),
620+
Some(a.azure_api_version.clone()),
621+
)
622+
})
623+
.unwrap_or((None, None));
624+
(LlmVendor::OpenAi, m.api_url.clone(), None, dep_id, api_ver)
625+
}
626+
Model::Ollama { config: m } => (
627+
LlmVendor::OpenAiCompat,
628+
Some(m.api_url.clone()),
629+
Some(m.api_key.clone()),
630+
None,
631+
None,
632+
),
633+
Model::Google { .. } => {
634+
tracing::warn!(
635+
model = name,
636+
"Google/Gemini models are not yet supported in analytics agents"
637+
);
638+
return None;
639+
}
640+
};
624641

625642
// Resolve api_key via secrets_manager first, env fallback. Ollama
626643
// carries its key inline via the config — honor that.
@@ -644,6 +661,8 @@ async fn resolve_model_impl(
644661
api_key,
645662
base_url,
646663
is_explicit_ref,
664+
azure_deployment_id,
665+
azure_api_version,
647666
})
648667
}
649668
Err(e) => {

0 commit comments

Comments
 (0)