Skip to content
Open
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
8 changes: 7 additions & 1 deletion charts/openab/templates/gateway.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,16 @@ spec:
value: "false"
{{- end }}
{{- end }}
{{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken (($cfg.gateway).googleChat).audience }}
{{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken (($cfg.gateway).googleChat).audience (($cfg.gateway).googleChat).useAdc }}
{{- if $hasGoogleChat }}
- name: GOOGLE_CHAT_ENABLED
value: "true"
{{- if (($cfg.gateway).googleChat).useAdc }}
- name: GOOGLE_CHAT_USE_ADC
value: "true"
- name: GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT
value: {{ required "gateway.googleChat.adcTargetServiceAccount is required when useAdc=true" (($cfg.gateway).googleChat).adcTargetServiceAccount | quote }}
{{- end }}
{{- if (($cfg.gateway).googleChat).audience }}
- name: GOOGLE_CHAT_AUDIENCE
value: {{ ($cfg.gateway).googleChat.audience | quote }}
Expand Down
2 changes: 2 additions & 0 deletions charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,8 @@ agents:
audience: "" # JWT audience → GOOGLE_CHAT_AUDIENCE (set to your webhook URL to enable JWT verification)
saKeyJson: "" # Service account key JSON string → GOOGLE_CHAT_SA_KEY_JSON (recommended, auto-refresh)
accessToken: "" # Static OAuth2 access token → GOOGLE_CHAT_ACCESS_TOKEN (fallback, 1-hour TTL)
useAdc: false # Keyless ADC → GOOGLE_CHAT_USE_ADC. Runtime SA impersonates the distinct adcTargetServiceAccount; no SA key file. Needs roles/iam.serviceAccountTokenCreator on the target + iamcredentials.googleapis.com. Self-impersonation is prohibited.
adcTargetServiceAccount: "" # Dedicated Chat-app SA email → GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT. Required when useAdc=true; MUST differ from the pod/runtime SA.
webhookPath: "" # Gateway default: /webhook/googlechat → GOOGLE_CHAT_WEBHOOK_PATH
# WeCom (企业微信) adapter config (gateway-side env vars)
# See docs/wecom.md for full setup guide
Expand Down
2 changes: 2 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
# sa_key_json = "${GOOGLE_CHAT_SA_KEY_JSON}" # inline SA key; wins over sa_key_file
# sa_key_file = "/etc/openab/sa.json" # env fallback: GOOGLE_CHAT_SA_KEY_FILE
# access_token = "${GOOGLE_CHAT_ACCESS_TOKEN}" # static token alternative
# use_adc = true # keyless ADC via GCE metadata + IAM Credentials; runtime SA impersonates a DISTINCT target (self-impersonation is prohibited). env: GOOGLE_CHAT_USE_ADC
# adc_target_service_account = "chat-bot@project.iam.gserviceaccount.com" # required with use_adc; env: GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT
# audience = "projects/<n>/..." # enables webhook JWT verification (L1)
# webhook_path = "/webhook/googlechat" # env fallback: GOOGLE_CHAT_WEBHOOK_PATH
# allow_all_users = false # env fallback: GOOGLE_CHAT_ALLOW_ALL_USERS
Expand Down
50 changes: 41 additions & 9 deletions crates/openab-core/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ fn reply_message_limit(platform: &str, adapter_limit: usize) -> usize {
}
}

/// Whether to use cosmetic streaming (placeholder + in-place edits) for
/// `platform`, given the adapter's own preference. Forces send-once for:
/// - `acp`: streams append-only `agent_message_chunk` deltas, not edits.
/// - platforms in `NON_STREAMING_PLATFORMS` — no message-edit API, or an edit
/// API that cannot be driven per-token (e.g. `googlechat`) — see
/// `NON_STREAMING_PLATFORMS` for the per-platform rationale and
/// `platform_supports_streaming`.
///
/// This is the embedded/unified dispatch gate; the WebSocket
/// `run_gateway_adapter` path applies the same `platform_supports_streaming`
/// check but has no `acp` case (ACP is embedded-only). The two gates are
/// siblings: adding a platform to `NON_STREAMING_PLATFORMS` covers both
/// paths, while an embedded-only carve-out belongs here.
fn resolve_streaming(platform: &str, adapter_prefers_streaming: bool) -> bool {
platform != "acp"
&& crate::gateway::platform_supports_streaming(platform)
&& adapter_prefers_streaming
}

/// Parse `[[key:value]]` directives from the beginning of agent output.
/// Returns parsed directives and the remaining content (directives stripped).
pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) {
Expand Down Expand Up @@ -701,15 +720,14 @@ impl AdapterRouter {
let adapter = adapter.clone();
let thread_channel = thread_channel.clone();
let message_limit = reply_message_limit(&thread_channel.platform, adapter.message_limit());
// ACP must not inherit the unified adapter's Telegram streaming flag (wrong
// coupling): it streams append-only `agent_message_chunk` deltas built from the
// post+edit (`edit_message` snapshot) path, i.e. streaming=false. Decide it
// explicitly by platform rather than by whatever Telegram happens to be set to.
let streaming = if thread_channel.platform == "acp" {
false
} else {
adapter.use_streaming(other_bot_present)
};
// Decide streaming explicitly by platform, not by whatever the unified
// adapter's Telegram flag happens to be. ACP streams append-only deltas
// (not cosmetic edits); Google Chat / LINE can't sustain in-place edits.
// See `resolve_streaming`.
let streaming = resolve_streaming(
&thread_channel.platform,
adapter.use_streaming(other_bot_present),
);
// Keep the full turn text (incl. inter-tool narration) when streaming
// (it was already shown live) OR when `[reactions] narration_display` is
// set. Otherwise a send-once turn delivers only the final answer block.
Expand Down Expand Up @@ -1746,6 +1764,20 @@ mod tests {
assert_eq!(crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), 1);
}

#[test]
fn resolve_streaming_forces_send_once_for_acp_and_googlechat() {
// Editable platforms honor the adapter's own streaming preference.
assert!(resolve_streaming("discord", true));
assert!(!resolve_streaming("discord", false));
assert!(resolve_streaming("telegram", true));
// ACP streams append-only deltas, not cosmetic edits → always send-once.
assert!(!resolve_streaming("acp", true));
// Google Chat: synthetic id can't be patched (400 INVALID_ARGUMENT) → send-once regardless of pref.
assert!(!resolve_streaming("googlechat", true));
// LINE: no edit API → send-once.
assert!(!resolve_streaming("line", true));
}

#[test]
fn select_delivery_text_send_once_keeps_only_final_block() {
// Simulates: narration "n1" → tool (answer_start→2) → narration "n2"
Expand Down
39 changes: 39 additions & 0 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,16 @@ pub struct GoogleChatConfig {
/// checked when `allow_all_users` resolves to `false`. Env fallback:
/// `GOOGLE_CHAT_ALLOWED_USERS` (comma-separated).
pub allowed_users: Option<Vec<String>>,
/// Use keyless ADC (GCE metadata server + IAM Credentials
/// `generateAccessToken`) to mint the `chat.bot` token for a distinct target
/// service account. Env fallback: `GOOGLE_CHAT_USE_ADC` (`true`/`1`; default
/// false). Ignored when a configured SA key loads successfully.
pub use_adc: Option<bool>,
/// Dedicated Google Chat service account impersonated by the attached
/// runtime service account. Required when `use_adc=true`; MUST differ from
/// the runtime identity because Google prohibits access-token
/// self-impersonation. Env: `GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT`.
pub adc_target_service_account: Option<String>,
}

/// Fully resolved Google Chat settings (config → env → default applied).
Expand All @@ -1234,6 +1244,8 @@ pub struct ResolvedGoogleChat {
pub sa_key_json: Option<String>,
pub sa_key_file: Option<String>,
pub access_token: Option<String>,
pub use_adc: bool,
pub adc_target_service_account: Option<String>,
pub audience: Option<String>,
pub webhook_path: String,
pub allow_all_users: bool,
Expand All @@ -1259,6 +1271,15 @@ impl GoogleChatConfig {
sa_key_json: opt_str(&self.sa_key_json, "GOOGLE_CHAT_SA_KEY_JSON"),
sa_key_file: opt_str(&self.sa_key_file, "GOOGLE_CHAT_SA_KEY_FILE"),
access_token: opt_str(&self.access_token, "GOOGLE_CHAT_ACCESS_TOKEN"),
use_adc: self.use_adc.unwrap_or_else(|| {
std::env::var("GOOGLE_CHAT_USE_ADC")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}),
adc_target_service_account: opt_str(
&self.adc_target_service_account,
"GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT",
),
audience: opt_str(&self.audience, "GOOGLE_CHAT_AUDIENCE"),
webhook_path: opt_str(&self.webhook_path, "GOOGLE_CHAT_WEBHOOK_PATH")
.unwrap_or_else(|| "/webhook/googlechat".into()),
Expand Down Expand Up @@ -2966,6 +2987,8 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
"GOOGLE_CHAT_SA_KEY_JSON",
"GOOGLE_CHAT_SA_KEY_FILE",
"GOOGLE_CHAT_ACCESS_TOKEN",
"GOOGLE_CHAT_USE_ADC",
"GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT",
"GOOGLE_CHAT_AUDIENCE",
"GOOGLE_CHAT_WEBHOOK_PATH",
] {
Expand All @@ -2974,9 +2997,25 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
// --- defaults ---
let r = GoogleChatConfig::default().resolve();
assert!(!r.enabled);
assert!(!r.use_adc);
assert!(r.audience.is_none());
assert_eq!(r.webhook_path, "/webhook/googlechat");

// --- use_adc: config value resolves without touching env ---
let r = GoogleChatConfig {
use_adc: Some(true),
adc_target_service_account: Some(
"chat-bot@project.iam.gserviceaccount.com".into(),
),
..Default::default()
}
.resolve();
assert!(r.use_adc);
assert_eq!(
r.adc_target_service_account.as_deref(),
Some("chat-bot@project.iam.gserviceaccount.com")
);

// --- config wins over env ---
std::env::set_var("GOOGLE_CHAT_ENABLED", "true");
std::env::set_var("GOOGLE_CHAT_AUDIENCE", "env-aud");
Expand Down
Loading
Loading