Surface resolved Databricks model metadata (#9206)
Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
@@ -20,9 +20,10 @@ use goose::config::declarative_providers::{
|
||||
DeclarativeProviderConfig, EnvVarConfig, LoadedProvider, ProviderEngine,
|
||||
};
|
||||
use goose::conversation::message::{
|
||||
ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent,
|
||||
MessageMetadata, RedactedThinkingContent, SystemNotificationContent, SystemNotificationType,
|
||||
ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, ToolResponse,
|
||||
ActionRequired, ActionRequiredData, FrontendToolRequest, InferenceMetadata, Message,
|
||||
MessageContent, MessageMetadata, RedactedThinkingContent, SystemNotificationContent,
|
||||
SystemNotificationType, ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest,
|
||||
ToolResponse,
|
||||
};
|
||||
|
||||
use crate::routes::recipe_utils::RecipeManifest;
|
||||
@@ -528,6 +529,7 @@ derive_utoipa!(IconTheme as IconThemeSchema);
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageMetadata,
|
||||
InferenceMetadata,
|
||||
TokenState,
|
||||
ContentSchema,
|
||||
EmbeddedResourceSchema,
|
||||
|
||||
@@ -34,8 +34,8 @@ use crate::context_mgmt::{
|
||||
check_if_compaction_needed, compact_messages, DEFAULT_COMPACTION_THRESHOLD,
|
||||
};
|
||||
use crate::conversation::message::{
|
||||
ActionRequiredData, Message, MessageContent, ProviderMetadata, SystemNotificationType,
|
||||
ToolRequest,
|
||||
ActionRequiredData, InferenceMetadata, Message, MessageContent, ProviderMetadata,
|
||||
SystemNotificationType, ToolRequest,
|
||||
};
|
||||
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
|
||||
use crate::mcp_utils::ToolResult;
|
||||
@@ -1586,9 +1586,22 @@ impl Agent {
|
||||
self.reset_retry_attempts().await;
|
||||
|
||||
let provider = self.provider().await?;
|
||||
let provider_name = provider.get_name().to_string();
|
||||
let requested_model = provider.get_model_config().model_name;
|
||||
let inference = provider
|
||||
.fetch_model_info(&requested_model)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|model_info| model_info.resolved_model)
|
||||
.map(|resolved_model| InferenceMetadata {
|
||||
provider: provider_name,
|
||||
requested_model,
|
||||
resolved_model: Some(resolved_model),
|
||||
});
|
||||
let session_manager = self.config.session_manager.clone();
|
||||
let session_id = session_config.id.clone();
|
||||
if !self.config.disable_session_naming {
|
||||
let provider = provider.clone();
|
||||
let manager_for_spawn = session_manager.clone();
|
||||
let session_name_update_tx = self.config.session_name_update_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -1726,6 +1739,17 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
let filtered_response = if let Some(inference) = inference.as_ref() {
|
||||
filtered_response.with_inference(inference.clone())
|
||||
} else {
|
||||
filtered_response
|
||||
};
|
||||
let response = if let Some(inference) = inference.as_ref() {
|
||||
response.with_inference(inference.clone())
|
||||
} else {
|
||||
response
|
||||
};
|
||||
|
||||
surfaced_thinking_in_turn |= filtered_response.content.iter().any(
|
||||
|content| {
|
||||
matches!(
|
||||
@@ -2234,6 +2258,16 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
let messages_to_add = if let Some(ref inference) = inference {
|
||||
Conversation::new_unvalidated(
|
||||
messages_to_add
|
||||
.into_iter()
|
||||
.map(|message| message.with_inference_if_assistant(inference.clone())),
|
||||
)
|
||||
} else {
|
||||
messages_to_add
|
||||
};
|
||||
|
||||
for msg in &messages_to_add {
|
||||
session_manager.add_message(&session_config.id, msg).await?;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ pub async fn compact_messages(
|
||||
// This is the most recent message and we're preserving it by adding a fresh copy
|
||||
MessageMetadata::invisible()
|
||||
} else {
|
||||
msg.metadata.with_agent_invisible()
|
||||
msg.metadata.clone().with_agent_invisible()
|
||||
};
|
||||
let updated_msg = msg.clone().with_metadata(updated_metadata);
|
||||
final_messages.push(updated_msg);
|
||||
|
||||
@@ -641,14 +641,25 @@ impl From<PromptMessage> for Message {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ToSchema, Clone, Copy, PartialEq, Serialize, Deserialize, Debug)]
|
||||
/// Metadata for message visibility
|
||||
#[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InferenceMetadata {
|
||||
pub provider: String,
|
||||
pub requested_model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
/// Metadata for message visibility and model inference details
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MessageMetadata {
|
||||
/// Whether the message should be visible to the user in the UI
|
||||
pub user_visible: bool,
|
||||
/// Whether the message should be included in the agent's context window
|
||||
pub agent_visible: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inference: Option<InferenceMetadata>,
|
||||
}
|
||||
|
||||
impl Default for MessageMetadata {
|
||||
@@ -656,6 +667,7 @@ impl Default for MessageMetadata {
|
||||
MessageMetadata {
|
||||
user_visible: true,
|
||||
agent_visible: true,
|
||||
inference: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -666,6 +678,7 @@ impl MessageMetadata {
|
||||
MessageMetadata {
|
||||
user_visible: false,
|
||||
agent_visible: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +687,7 @@ impl MessageMetadata {
|
||||
MessageMetadata {
|
||||
user_visible: true,
|
||||
agent_visible: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +696,7 @@ impl MessageMetadata {
|
||||
MessageMetadata {
|
||||
user_visible: false,
|
||||
agent_visible: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,6 +731,11 @@ impl MessageMetadata {
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_inference(mut self, inference: InferenceMetadata) -> Self {
|
||||
self.inference = Some(inference);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
@@ -996,6 +1016,19 @@ impl Message {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_inference(mut self, inference: InferenceMetadata) -> Self {
|
||||
self.metadata = self.metadata.with_inference(inference);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_inference_if_assistant(self, inference: InferenceMetadata) -> Self {
|
||||
if self.role == Role::Assistant && self.metadata.inference.is_none() {
|
||||
self.with_inference(inference)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_only(mut self) -> Self {
|
||||
self.metadata.user_visible = true;
|
||||
self.metadata.agent_visible = false;
|
||||
|
||||
@@ -43,11 +43,26 @@ impl Conversation {
|
||||
}
|
||||
|
||||
pub fn push(&mut self, message: Message) {
|
||||
if message.content.is_empty() && message.metadata.inference.is_some() {
|
||||
if let Some(existing) = self
|
||||
.0
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|m| m.role == message.role && m.is_user_visible())
|
||||
{
|
||||
existing.metadata.inference = message.metadata.inference;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(last) = self
|
||||
.0
|
||||
.last_mut()
|
||||
.filter(|m| m.id.is_some() && m.id == message.id)
|
||||
{
|
||||
if message.metadata.inference.is_some() {
|
||||
last.metadata.inference = message.metadata.inference.clone();
|
||||
}
|
||||
match (last.content.last_mut(), message.content.last()) {
|
||||
(Some(MessageContent::Text(ref mut last)), Some(MessageContent::Text(new)))
|
||||
if message.content.len() == 1 =>
|
||||
|
||||
@@ -385,6 +385,9 @@ pub static MSG_COUNT_FOR_SESSION_NAME_GENERATION: usize = 3;
|
||||
pub struct ModelInfo {
|
||||
/// The name of the model
|
||||
pub name: String,
|
||||
/// The underlying model resolved from provider metadata, when the configured model is an alias or endpoint.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_model: Option<String>,
|
||||
/// The maximum context length this model supports
|
||||
pub context_limit: usize,
|
||||
/// Cost per token for input in USD (optional)
|
||||
@@ -405,6 +408,7 @@ impl ModelInfo {
|
||||
pub fn new(name: impl Into<String>, context_limit: usize) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
resolved_model: None,
|
||||
context_limit,
|
||||
input_token_cost: None,
|
||||
output_token_cost: None,
|
||||
@@ -423,6 +427,7 @@ impl ModelInfo {
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
resolved_model: None,
|
||||
context_limit,
|
||||
input_token_cost: Some(input_cost),
|
||||
output_token_cost: Some(output_cost),
|
||||
@@ -448,6 +453,7 @@ fn model_info_for_provider_model(provider_name: &str, model_name: &str) -> Model
|
||||
|
||||
ModelInfo {
|
||||
name: model_name.to_string(),
|
||||
resolved_model: None,
|
||||
context_limit: ModelConfig::new_or_fail(model_name)
|
||||
.with_canonical_limits(provider_name)
|
||||
.context_limit(),
|
||||
@@ -1778,6 +1784,7 @@ mod tests {
|
||||
// Test direct ModelInfo creation
|
||||
let info = ModelInfo {
|
||||
name: "test-model".to_string(),
|
||||
resolved_model: None,
|
||||
context_limit: 1000,
|
||||
input_token_cost: None,
|
||||
output_token_cost: None,
|
||||
@@ -1790,6 +1797,7 @@ mod tests {
|
||||
// Test equality
|
||||
let info2 = ModelInfo {
|
||||
name: "test-model".to_string(),
|
||||
resolved_model: None,
|
||||
context_limit: 1000,
|
||||
input_token_cost: None,
|
||||
output_token_cost: None,
|
||||
@@ -1802,6 +1810,7 @@ mod tests {
|
||||
// Test inequality
|
||||
let info3 = ModelInfo {
|
||||
name: "test-model".to_string(),
|
||||
resolved_model: None,
|
||||
context_limit: 2000,
|
||||
input_token_cost: None,
|
||||
output_token_cost: None,
|
||||
|
||||
@@ -539,6 +539,7 @@ impl DatabricksProvider {
|
||||
|
||||
ModelInfo {
|
||||
name: info.name,
|
||||
resolved_model: info.upstream_model_name,
|
||||
context_limit,
|
||||
input_token_cost: None,
|
||||
output_token_cost: None,
|
||||
@@ -999,6 +1000,14 @@ mod tests {
|
||||
assert_eq!(info.name, "goose");
|
||||
assert_eq!(info.upstream_model_name.as_deref(), Some("claude-opus-4.6"));
|
||||
assert_eq!(info.reasoning, Some(true));
|
||||
|
||||
let model_info = DatabricksProvider::model_info_from_endpoint(info);
|
||||
assert_eq!(model_info.name, "goose");
|
||||
assert_eq!(
|
||||
model_info.resolved_model.as_deref(),
|
||||
Some("claude-opus-4.6")
|
||||
);
|
||||
assert!(model_info.reasoning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -734,15 +734,22 @@ pub fn get_usage(usage: &Value) -> Usage {
|
||||
.with_cache_tokens(cache_read_input_tokens, cache_write_input_tokens)
|
||||
}
|
||||
|
||||
fn extract_usage_with_output_tokens(chunk: &StreamingChunk) -> Option<ProviderUsage> {
|
||||
fn extract_usage_with_output_tokens(
|
||||
chunk: &StreamingChunk,
|
||||
fallback_model: Option<&str>,
|
||||
) -> Option<ProviderUsage> {
|
||||
chunk
|
||||
.usage
|
||||
.as_ref()
|
||||
.and_then(|u| {
|
||||
chunk.model.as_ref().map(|model| ProviderUsage {
|
||||
usage: get_usage(u),
|
||||
model: model.clone(),
|
||||
})
|
||||
chunk
|
||||
.model
|
||||
.as_deref()
|
||||
.or(fallback_model)
|
||||
.map(|model| ProviderUsage {
|
||||
usage: get_usage(u),
|
||||
model: model.to_string(),
|
||||
})
|
||||
})
|
||||
.filter(|u| u.usage.output_tokens.is_some())
|
||||
}
|
||||
@@ -901,6 +908,7 @@ where
|
||||
// reasoning will arrive. Emitting it immediately and then receiving
|
||||
// reasoning_content in a later chunk would produce duplicated reasoning.
|
||||
let mut pending_inline_thinking = String::new();
|
||||
let mut last_seen_model: Option<String> = None;
|
||||
|
||||
'outer: while let Some(response) = stream.next().await {
|
||||
let response_str = response?;
|
||||
@@ -917,6 +925,9 @@ where
|
||||
let chunk: StreamingChunk = parse_streaming_chunk(
|
||||
line.ok_or_else(|| anyhow!("unexpected stream format"))?
|
||||
)?;
|
||||
if let Some(model) = &chunk.model {
|
||||
last_seen_model = Some(model.clone());
|
||||
}
|
||||
|
||||
if !chunk.choices.is_empty() {
|
||||
if let Some(details) = &chunk.choices[0].delta.reasoning_details {
|
||||
@@ -931,7 +942,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
let mut usage = extract_usage_with_output_tokens(&chunk);
|
||||
let mut usage = extract_usage_with_output_tokens(&chunk, last_seen_model.as_deref());
|
||||
|
||||
if chunk.choices.is_empty() {
|
||||
yield (None, usage)
|
||||
@@ -959,8 +970,11 @@ where
|
||||
}
|
||||
|
||||
let tool_chunk: StreamingChunk = parse_streaming_chunk(line)?;
|
||||
if let Some(model) = &tool_chunk.model {
|
||||
last_seen_model = Some(model.clone());
|
||||
}
|
||||
|
||||
if let Some(chunk_usage) = extract_usage_with_output_tokens(&tool_chunk) {
|
||||
if let Some(chunk_usage) = extract_usage_with_output_tokens(&tool_chunk, last_seen_model.as_deref()) {
|
||||
usage = Some(chunk_usage);
|
||||
}
|
||||
|
||||
@@ -2528,6 +2542,10 @@ data: [DONE]
|
||||
assert_eq!(result.tool_calls.len(), 1, "Expected 1 tool call");
|
||||
assert_eq!(result.tool_calls[0], "developer__shell");
|
||||
assert_usage_yielded_once(&result, 8320, 172, 8492);
|
||||
assert_eq!(
|
||||
result.usage.as_ref().map(|usage| usage.model.as_str()),
|
||||
Some("gpt-5.2-1106-preview")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ impl ProviderRegistry {
|
||||
.iter()
|
||||
.map(|m| ModelInfo {
|
||||
name: m.name.clone(),
|
||||
resolved_model: None,
|
||||
context_limit: m.context_limit,
|
||||
input_token_cost: m.input_token_cost,
|
||||
output_token_cost: m.output_token_cost,
|
||||
|
||||
+33
-1
@@ -5799,6 +5799,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"InferenceMetadata": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"provider",
|
||||
"requestedModel"
|
||||
],
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestedModel": {
|
||||
"type": "string"
|
||||
},
|
||||
"resolvedModel": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"InspectJobResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6394,7 +6413,7 @@
|
||||
},
|
||||
"MessageMetadata": {
|
||||
"type": "object",
|
||||
"description": "Metadata for message visibility",
|
||||
"description": "Metadata for message visibility and model inference details",
|
||||
"required": [
|
||||
"userVisible",
|
||||
"agentVisible"
|
||||
@@ -6404,6 +6423,14 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the message should be included in the agent's context window"
|
||||
},
|
||||
"inference": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InferenceMetadata"
|
||||
}
|
||||
],
|
||||
"nullable": true
|
||||
},
|
||||
"userVisible": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the message should be visible to the user in the UI"
|
||||
@@ -6587,6 +6614,11 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether this model supports reasoning/thinking controls"
|
||||
},
|
||||
"resolved_model": {
|
||||
"type": "string",
|
||||
"description": "The underlying model resolved from provider metadata, when the configured model is an alias or endpoint.",
|
||||
"nullable": true
|
||||
},
|
||||
"supports_cache_control": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this model supports cache control",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -602,6 +602,12 @@ export type ImportSessionRequest = {
|
||||
json: string;
|
||||
};
|
||||
|
||||
export type InferenceMetadata = {
|
||||
provider: string;
|
||||
requestedModel: string;
|
||||
resolvedModel?: string | null;
|
||||
};
|
||||
|
||||
export type InspectJobResponse = {
|
||||
processStartTime?: string | null;
|
||||
runningDurationSeconds?: number | null;
|
||||
@@ -746,13 +752,14 @@ export type MessageEvent = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata for message visibility
|
||||
* Metadata for message visibility and model inference details
|
||||
*/
|
||||
export type MessageMetadata = {
|
||||
/**
|
||||
* Whether the message should be included in the agent's context window
|
||||
*/
|
||||
agentVisible: boolean;
|
||||
inference?: InferenceMetadata | null;
|
||||
/**
|
||||
* Whether the message should be visible to the user in the UI
|
||||
*/
|
||||
@@ -822,6 +829,10 @@ export type ModelInfo = {
|
||||
* Whether this model supports reasoning/thinking controls
|
||||
*/
|
||||
reasoning?: boolean;
|
||||
/**
|
||||
* The underlying model resolved from provider metadata, when the configured model is an alias or endpoint.
|
||||
*/
|
||||
resolved_model?: string | null;
|
||||
/**
|
||||
* Whether this model supports cache control
|
||||
*/
|
||||
|
||||
@@ -196,6 +196,15 @@ export default function BaseChat({
|
||||
const sessionModel = session?.model_config?.model_name ?? null;
|
||||
const sessionProvider = session?.provider_name ?? null;
|
||||
const sessionLoaded = session !== undefined;
|
||||
const latestInference = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.role === 'assistant' && message.metadata.userVisible && message.metadata.inference) {
|
||||
return message.metadata.inference;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recipe || !isActiveSession) return;
|
||||
@@ -508,6 +517,7 @@ export default function BaseChat({
|
||||
sessionModel={sessionModel}
|
||||
sessionProvider={sessionProvider}
|
||||
sessionLoaded={sessionLoaded}
|
||||
latestInference={latestInference}
|
||||
{...customChatInputProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -204,6 +204,7 @@ interface ChatInputProps {
|
||||
sessionModel?: string | null;
|
||||
sessionProvider?: string | null;
|
||||
sessionLoaded?: boolean;
|
||||
latestInference?: Message['metadata']['inference'] | null;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
@@ -234,6 +235,7 @@ export default function ChatInput({
|
||||
sessionModel,
|
||||
sessionProvider,
|
||||
sessionLoaded,
|
||||
latestInference,
|
||||
}: ChatInputProps) {
|
||||
const [_value, setValue] = useState(initialValue);
|
||||
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
||||
@@ -1757,6 +1759,7 @@ export default function ChatInput({
|
||||
setView={setView}
|
||||
sessionModel={effectiveModel}
|
||||
sessionProvider={effectiveProvider}
|
||||
latestInference={latestInference}
|
||||
onModelChanged={setModelOverride}
|
||||
sessionLoaded={sessionLoaded}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* - Configurable batch size and delay
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from '../i18n';
|
||||
import { Message, SystemNotificationContent } from '../api';
|
||||
import GooseMessage from './GooseMessage';
|
||||
@@ -31,6 +31,7 @@ import { NotificationEvent } from '../types/message';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { identifyConsecutiveToolCalls, isInChain } from '../utils/toolCallChaining';
|
||||
import { getModelDisplayName } from './settings/models/predefinedModelsUtils';
|
||||
|
||||
const i18n = defineMessages({
|
||||
loadingMessages: {
|
||||
@@ -41,6 +42,10 @@ const i18n = defineMessages({
|
||||
id: 'progressiveMessageList.searchHint',
|
||||
defaultMessage: 'Press Cmd/Ctrl+F to load all messages immediately for search',
|
||||
},
|
||||
modelChanged: {
|
||||
id: 'progressiveMessageList.modelChanged',
|
||||
defaultMessage: 'Model changed: {previousModel} → {currentModel}',
|
||||
},
|
||||
});
|
||||
|
||||
interface ProgressiveMessageListProps {
|
||||
@@ -91,6 +96,37 @@ export default function ProgressiveMessageList({
|
||||
const hasOnlyToolResponses = (message: Message) =>
|
||||
message.content.every((c) => c.type === 'toolResponse');
|
||||
|
||||
const getResolvedModel = useCallback((message: Message): string | null => {
|
||||
if (message.role !== 'assistant' || !message.metadata.userVisible) return null;
|
||||
return message.metadata.inference?.resolvedModel ?? null;
|
||||
}, []);
|
||||
|
||||
const getPreviousResolvedModel = useCallback(
|
||||
(index: number): string | null => {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const model = getResolvedModel(messages[i]);
|
||||
if (model) return model;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[getResolvedModel, messages]
|
||||
);
|
||||
|
||||
const renderModelChangeDisclosure = useCallback(
|
||||
(previousModel: string, currentModel: string) => (
|
||||
<SystemNotificationInline
|
||||
notification={{
|
||||
msg: intl.formatMessage(i18n.modelChanged, {
|
||||
previousModel: getModelDisplayName(previousModel),
|
||||
currentModel: getModelDisplayName(currentModel),
|
||||
}),
|
||||
notificationType: 'inlineMessage',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[intl]
|
||||
);
|
||||
|
||||
const getSystemNotification = (message: Message): SystemNotificationContent | undefined => {
|
||||
return getCreditsExhaustedNotification(message) ?? getInlineSystemNotification(message);
|
||||
};
|
||||
@@ -231,34 +267,46 @@ export default function ProgressiveMessageList({
|
||||
|
||||
const isUser = isUserMessage(message);
|
||||
const messageIsInChain = isInChain(index, toolCallChains);
|
||||
const currentResolvedModel = getResolvedModel(message);
|
||||
const previousResolvedModel = currentResolvedModel ? getPreviousResolvedModel(index) : null;
|
||||
const showModelChangeDisclosure = Boolean(
|
||||
currentResolvedModel &&
|
||||
previousResolvedModel &&
|
||||
currentResolvedModel !== previousResolvedModel
|
||||
);
|
||||
|
||||
const messageKey = message.id ?? `msg-${index}-${message.created}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id ?? `msg-${index}-${message.created}`}
|
||||
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'} ${messageIsInChain ? 'in-chain' : ''}`}
|
||||
data-testid="message-container"
|
||||
>
|
||||
{isUser ? (
|
||||
!hasOnlyToolResponses(message) && (
|
||||
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
|
||||
)
|
||||
) : (
|
||||
<GooseMessage
|
||||
sessionId={chat.sessionId}
|
||||
message={message}
|
||||
messages={messages}
|
||||
append={append}
|
||||
toolCallNotifications={toolCallNotifications}
|
||||
isStreaming={
|
||||
isStreamingMessage &&
|
||||
!isUser &&
|
||||
index === messagesToRender.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
}
|
||||
submitElicitationResponse={submitElicitationResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Fragment key={messageKey}>
|
||||
{showModelChangeDisclosure && currentResolvedModel && previousResolvedModel &&
|
||||
renderModelChangeDisclosure(previousResolvedModel, currentResolvedModel)}
|
||||
<div
|
||||
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'} ${messageIsInChain ? 'in-chain' : ''}`}
|
||||
data-testid="message-container"
|
||||
>
|
||||
{isUser ? (
|
||||
!hasOnlyToolResponses(message) && (
|
||||
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
|
||||
)
|
||||
) : (
|
||||
<GooseMessage
|
||||
sessionId={chat.sessionId}
|
||||
message={message}
|
||||
messages={messages}
|
||||
append={append}
|
||||
toolCallNotifications={toolCallNotifications}
|
||||
isStreaming={
|
||||
isStreamingMessage &&
|
||||
!isUser &&
|
||||
index === messagesToRender.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
}
|
||||
submitElicitationResponse={submitElicitationResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
@@ -274,6 +322,9 @@ export default function ProgressiveMessageList({
|
||||
onMessageUpdate,
|
||||
toolCallChains,
|
||||
submitElicitationResponse,
|
||||
getPreviousResolvedModel,
|
||||
getResolvedModel,
|
||||
renderModelChangeDisclosure,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Sliders, Bot, LoaderCircle, Settings } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import { SwitchModelModal } from '../subcomponents/SwitchModelModal';
|
||||
import { View } from '../../../../utils/navigationUtils';
|
||||
@@ -16,6 +16,7 @@ import { getModelDisplayName } from '../predefinedModelsUtils';
|
||||
import { ModelSettingsPanel } from '../../localInference/ModelSettingsPanel';
|
||||
import { ScrollArea } from '../../../ui/scroll-area';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
import type { Message } from '../../../../api';
|
||||
|
||||
const i18n = defineMessages({
|
||||
selectModel: {
|
||||
@@ -42,6 +43,10 @@ const i18n = defineMessages({
|
||||
id: 'modelsBottomBar.localModelSettingsTitle',
|
||||
defaultMessage: 'Local Model Settings — {modelName}',
|
||||
},
|
||||
resolvedModel: {
|
||||
id: 'modelsBottomBar.resolvedModel',
|
||||
defaultMessage: 'Resolved model',
|
||||
},
|
||||
});
|
||||
|
||||
interface ModelsBottomBarProps {
|
||||
@@ -50,6 +55,7 @@ interface ModelsBottomBarProps {
|
||||
setView: (view: View) => void;
|
||||
sessionModel?: string | null;
|
||||
sessionProvider?: string | null;
|
||||
latestInference?: Message['metadata']['inference'] | null;
|
||||
onModelChanged: (override: { model: string; provider: string }) => void;
|
||||
sessionLoaded?: boolean;
|
||||
}
|
||||
@@ -60,6 +66,7 @@ export default function ModelsBottomBar({
|
||||
setView,
|
||||
sessionModel,
|
||||
sessionProvider,
|
||||
latestInference,
|
||||
onModelChanged,
|
||||
sessionLoaded,
|
||||
}: ModelsBottomBarProps) {
|
||||
@@ -81,6 +88,14 @@ export default function ModelsBottomBar({
|
||||
// rather than flashing the config default or leaving the footer blank.
|
||||
const isModelLoading = Boolean(sessionId && !sessionLoaded);
|
||||
const displayModel = currentModel || providerDefaultModel || displayModelName;
|
||||
const resolvedModel = latestInference?.resolvedModel ?? null;
|
||||
const shouldShowResolvedModel = Boolean(
|
||||
!isModelLoading &&
|
||||
resolvedModel &&
|
||||
latestInference?.provider === currentProvider &&
|
||||
latestInference?.requestedModel === currentModel &&
|
||||
resolvedModel !== currentModel
|
||||
);
|
||||
const loadingModelLabel = intl.formatMessage(i18n.loadingModel);
|
||||
const triggerLabel = isModelLoading ? loadingModelLabel : displayModel;
|
||||
const menuModelLabel = isModelLoading ? loadingModelLabel : displayModelName;
|
||||
@@ -118,6 +133,11 @@ export default function ModelsBottomBar({
|
||||
setDisplayModelName(getModelDisplayName(currentModel));
|
||||
}, [currentModel]);
|
||||
|
||||
const resolvedDisplayModelName = useMemo(
|
||||
() => (resolvedModel ? getModelDisplayName(resolvedModel) : null),
|
||||
[resolvedModel]
|
||||
);
|
||||
|
||||
const handleModelSelected = (model: string, provider: string) => {
|
||||
onModelChanged({ model, provider });
|
||||
};
|
||||
@@ -147,6 +167,14 @@ export default function ModelsBottomBar({
|
||||
{menuModelLabel}
|
||||
{!isModelLoading && displayProvider && ` — ${displayProvider}`}
|
||||
</p>
|
||||
{shouldShowResolvedModel && resolvedDisplayModelName && (
|
||||
<div className="mx-2 pb-2 border-b mb-2">
|
||||
<h6 className="text-xs text-text-primary">{intl.formatMessage(i18n.resolvedModel)}</h6>
|
||||
<p className="text-xs text-text-primary truncate" title={resolvedModel ?? undefined}>
|
||||
{resolvedDisplayModelName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setIsAddModelModalOpen(true)}>
|
||||
<span>{intl.formatMessage(i18n.changeModel)}</span>
|
||||
<Sliders className="ml-auto h-4 w-4 rotate-90" />
|
||||
|
||||
@@ -183,6 +183,13 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
|
||||
const lastContent = lastMsg.content[lastMsg.content.length - 1];
|
||||
const newContent = incomingMsg.content[incomingMsg.content.length - 1];
|
||||
|
||||
if (incomingMsg.metadata?.inference) {
|
||||
lastMsg.metadata = {
|
||||
...lastMsg.metadata,
|
||||
inference: incomingMsg.metadata.inference,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lastContent?.type === 'text' &&
|
||||
newContent?.type === 'text' &&
|
||||
@@ -236,6 +243,7 @@ function createEventProcessor(
|
||||
let latestChatState: ChatState = ChatState.Streaming;
|
||||
let lastBatchUpdate = Date.now();
|
||||
let hasPendingUpdate = false;
|
||||
let pendingInference: Message['metadata']['inference'] | undefined;
|
||||
|
||||
const flushBatchedUpdates = () => {
|
||||
if (reduceMotion && hasPendingUpdate) {
|
||||
@@ -271,12 +279,54 @@ function createEventProcessor(
|
||||
}
|
||||
};
|
||||
|
||||
const flushPendingInference = () => {
|
||||
if (!pendingInference) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = currentMessages.length - 1; i >= 0; i--) {
|
||||
const message = currentMessages[i];
|
||||
if (message.role === 'assistant' && message.metadata.userVisible) {
|
||||
currentMessages = [
|
||||
...currentMessages.slice(0, i),
|
||||
{
|
||||
...message,
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
inference: message.metadata.inference ?? pendingInference,
|
||||
},
|
||||
},
|
||||
...currentMessages.slice(i + 1),
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
pendingInference = undefined;
|
||||
};
|
||||
|
||||
// Returns true if the event is terminal (Finish or Error)
|
||||
const processEvent = (event: SessionEvent): boolean => {
|
||||
switch (event.type) {
|
||||
case 'Message': {
|
||||
const msg = (event as Record<string, unknown>).message as Message;
|
||||
let msg = (event as Record<string, unknown>).message as Message;
|
||||
const tokenState = (event as Record<string, unknown>).token_state as TokenState;
|
||||
|
||||
if (msg.content.length === 0 && msg.metadata?.inference) {
|
||||
pendingInference = msg.metadata.inference;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pendingInference && msg.role === 'assistant' && msg.metadata.userVisible) {
|
||||
msg = {
|
||||
...msg,
|
||||
metadata: {
|
||||
...msg.metadata,
|
||||
inference: msg.metadata.inference ?? pendingInference,
|
||||
},
|
||||
};
|
||||
pendingInference = undefined;
|
||||
}
|
||||
|
||||
currentMessages = pushMessage(currentMessages, msg);
|
||||
|
||||
const hasToolConfirmation = msg.content.some(
|
||||
@@ -301,7 +351,9 @@ function createEventProcessor(
|
||||
return false;
|
||||
}
|
||||
case 'Error': {
|
||||
flushPendingInference();
|
||||
flushBatchedUpdates();
|
||||
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
|
||||
const errorMsg = String((event as Record<string, unknown>).error ?? '');
|
||||
if (errorMsg.includes('too far behind') && onReloadNeeded) {
|
||||
// Server indicated we missed events — end streaming without setting
|
||||
@@ -315,7 +367,9 @@ function createEventProcessor(
|
||||
return true;
|
||||
}
|
||||
case 'Finish': {
|
||||
flushPendingInference();
|
||||
flushBatchedUpdates();
|
||||
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
|
||||
onFinish();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2390,6 +2390,9 @@
|
||||
"modelsBottomBar.localModelSettingsTitle": {
|
||||
"defaultMessage": "Local Model Settings — {modelName}"
|
||||
},
|
||||
"modelsBottomBar.resolvedModel": {
|
||||
"defaultMessage": "Resolved model"
|
||||
},
|
||||
"modelsBottomBar.selectModel": {
|
||||
"defaultMessage": "Select Model"
|
||||
},
|
||||
@@ -2687,6 +2690,9 @@
|
||||
"progressiveMessageList.loadingMessages": {
|
||||
"defaultMessage": "Loading messages... ({renderedCount}/{totalCount})"
|
||||
},
|
||||
"progressiveMessageList.modelChanged": {
|
||||
"defaultMessage": "Model changed: {previousModel} → {currentModel}"
|
||||
},
|
||||
"progressiveMessageList.searchHint": {
|
||||
"defaultMessage": "Press Cmd/Ctrl+F to load all messages immediately for search"
|
||||
},
|
||||
|
||||
@@ -2333,6 +2333,9 @@
|
||||
"modelsBottomBar.localModelSettingsTitle": {
|
||||
"defaultMessage": "本地模型设置 — {modelName}"
|
||||
},
|
||||
"modelsBottomBar.resolvedModel": {
|
||||
"defaultMessage": "解析后的模型"
|
||||
},
|
||||
"modelsBottomBar.selectModel": {
|
||||
"defaultMessage": "选择模型"
|
||||
},
|
||||
@@ -2630,6 +2633,9 @@
|
||||
"progressiveMessageList.loadingMessages": {
|
||||
"defaultMessage": "正在加载消息…({renderedCount}/{totalCount})"
|
||||
},
|
||||
"progressiveMessageList.modelChanged": {
|
||||
"defaultMessage": "模型已更改:{previousModel} → {currentModel}"
|
||||
},
|
||||
"progressiveMessageList.searchHint": {
|
||||
"defaultMessage": "按 Cmd/Ctrl+F 立即加载全部消息以便搜索"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user