refactor: Use openapi for recipe endpoint types and in frontend (#3548)

This commit is contained in:
Jarrod Sibbison
2025-07-21 19:40:13 +10:00
committed by GitHub
parent a996227381
commit d085126709
10 changed files with 863 additions and 132 deletions
+37 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RecoverConfigData, RecoverConfigResponse, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ValidateConfigData, ValidateConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RecoverConfigData, RecoverConfigResponse, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ValidateConfigData, ValidateConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateRecipeData, CreateRecipeResponse2, DecodeRecipeData, DecodeRecipeResponse2, EncodeRecipeData, EncodeRecipeResponse2, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -158,6 +158,42 @@ export const manageContext = <ThrowOnError extends boolean = false>(options: Opt
});
};
/**
* Create a Recipe configuration from the current session
*/
export const createRecipe = <ThrowOnError extends boolean = false>(options: Options<CreateRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<CreateRecipeResponse2, unknown, ThrowOnError>({
url: '/recipes/create',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const decodeRecipe = <ThrowOnError extends boolean = false>(options: Options<DecodeRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<DecodeRecipeResponse2, unknown, ThrowOnError>({
url: '/recipes/decode',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const encodeRecipe = <ThrowOnError extends boolean = false>(options: Options<EncodeRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<EncodeRecipeResponse2, unknown, ThrowOnError>({
url: '/recipes/encode',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const createSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<CreateScheduleResponse, unknown, ThrowOnError>({
url: '/schedule/create',
+219
View File
@@ -6,6 +6,16 @@ export type Annotations = {
timestamp?: string;
};
export type Author = {
contact?: string | null;
metadata?: string | null;
};
export type AuthorRequest = {
contact?: string | null;
metadata?: string | null;
};
export type ConfigKey = {
default?: string | null;
name: string;
@@ -71,6 +81,19 @@ export type ContextManageResponse = {
tokenCounts: Array<number>;
};
export type CreateRecipeRequest = {
activities?: Array<string> | null;
author?: AuthorRequest | null;
description: string;
messages: Array<Message>;
title: string;
};
export type CreateRecipeResponse = {
error?: string | null;
recipe?: Recipe | null;
};
export type CreateScheduleRequest = {
cron: string;
execution_mode?: string | null;
@@ -78,11 +101,27 @@ export type CreateScheduleRequest = {
recipe_source: string;
};
export type DecodeRecipeRequest = {
deeplink: string;
};
export type DecodeRecipeResponse = {
recipe: Recipe;
};
export type EmbeddedResource = {
annotations?: Annotations;
resource: ResourceContents;
};
export type EncodeRecipeRequest = {
recipe: Recipe;
};
export type EncodeRecipeResponse = {
deeplink: string;
};
export type Envs = {
[key: string]: string;
};
@@ -273,6 +312,10 @@ export type ModelInfo = {
* Cost per token for output (optional)
*/
output_token_cost?: number | null;
/**
* Whether this model supports cache control
*/
supports_cache_control?: boolean | null;
};
export type PermissionConfirmationRequest = {
@@ -333,6 +376,86 @@ export type ProvidersResponse = {
providers: Array<ProviderDetails>;
};
/**
* A Recipe represents a personalized, user-generated agent configuration that defines
* specific behaviors and capabilities within the Goose system.
*
* # Fields
*
* ## Required Fields
* * `version` - Semantic version of the Recipe file format (defaults to "1.0.0")
* * `title` - Short, descriptive name of the Recipe
* * `description` - Detailed description explaining the Recipe's purpose and functionality
* * `Instructions` - Instructions that defines the Recipe's behavior
*
* ## Optional Fields
* * `prompt` - the initial prompt to the session to start with
* * `extensions` - List of extension configurations required by the Recipe
* * `context` - Supplementary context information for the Recipe
* * `activities` - Activity labels that appear when loading the Recipe
* * `author` - Information about the Recipe's creator and metadata
* * `parameters` - Additional parameters for the Recipe
* * `response` - Response configuration including JSON schema validation
*
* # Example
*
*
* use goose::recipe::Recipe;
*
* // Using the builder pattern
* let recipe = Recipe::builder()
* .title("Example Agent")
* .description("An example Recipe configuration")
* .instructions("Act as a helpful assistant")
* .build()
* .expect("Missing required fields");
*
* // Or using struct initialization
* let recipe = Recipe {
* version: "1.0.0".to_string(),
* title: "Example Agent".to_string(),
* description: "An example Recipe configuration".to_string(),
* instructions: Some("Act as a helpful assistant".to_string()),
* prompt: None,
* extensions: None,
* context: None,
* activities: None,
* author: None,
* settings: None,
* parameters: None,
* response: None,
* sub_recipes: None,
* };
*
*/
export type Recipe = {
activities?: Array<string> | null;
author?: Author | null;
context?: Array<string> | null;
description: string;
extensions?: Array<ExtensionConfig> | null;
instructions?: string | null;
parameters?: Array<RecipeParameter> | null;
prompt?: string | null;
response?: Response | null;
settings?: Settings | null;
sub_recipes?: Array<SubRecipe> | null;
title: string;
version?: string;
};
export type RecipeParameter = {
default?: string | null;
description: string;
input_type: RecipeParameterInputType;
key: string;
requirement: RecipeParameterRequirement;
};
export type RecipeParameterInputType = 'string' | 'number' | 'boolean' | 'date' | 'file';
export type RecipeParameterRequirement = 'required' | 'optional' | 'user_prompt';
export type RedactedThinkingContent = {
data: string;
};
@@ -347,6 +470,10 @@ export type ResourceContents = {
uri: string;
};
export type Response = {
json_schema?: unknown;
};
export type Role = string;
export type RunNowResponse = {
@@ -460,6 +587,21 @@ export type SessionsQuery = {
limit?: number;
};
export type Settings = {
goose_model?: string | null;
goose_provider?: string | null;
temperature?: number | null;
};
export type SubRecipe = {
name: string;
path: string;
sequential_when_repeated?: boolean;
values?: {
[key: string]: string;
} | null;
};
export type SummarizationRequested = {
msg: string;
};
@@ -994,6 +1136,83 @@ export type ManageContextResponses = {
export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses];
export type CreateRecipeData = {
body: CreateRecipeRequest;
path?: never;
query?: never;
url: '/recipes/create';
};
export type CreateRecipeErrors = {
/**
* Bad request
*/
400: unknown;
/**
* Precondition failed - Agent not available
*/
412: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type CreateRecipeResponses = {
/**
* Recipe created successfully
*/
200: CreateRecipeResponse;
};
export type CreateRecipeResponse2 = CreateRecipeResponses[keyof CreateRecipeResponses];
export type DecodeRecipeData = {
body: DecodeRecipeRequest;
path?: never;
query?: never;
url: '/recipes/decode';
};
export type DecodeRecipeErrors = {
/**
* Bad request
*/
400: unknown;
};
export type DecodeRecipeResponses = {
/**
* Recipe decoded successfully
*/
200: DecodeRecipeResponse;
};
export type DecodeRecipeResponse2 = DecodeRecipeResponses[keyof DecodeRecipeResponses];
export type EncodeRecipeData = {
body: EncodeRecipeRequest;
path?: never;
query?: never;
url: '/recipes/encode';
};
export type EncodeRecipeErrors = {
/**
* Bad request
*/
400: unknown;
};
export type EncodeRecipeResponses = {
/**
* Recipe encoded successfully
*/
200: EncodeRecipeResponse;
};
export type EncodeRecipeResponse2 = EncodeRecipeResponses[keyof EncodeRecipeResponses];
export type CreateScheduleData = {
body: CreateScheduleRequest;
path?: never;
+6 -2
View File
@@ -163,8 +163,12 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
}
const recipe = await decodeRecipe(recipeEncoded);
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Recipe is missing required fields (title, description, instructions)');
if (!recipe.title || !recipe.description) {
throw new Error('Recipe is missing required fields (title, description)');
}
if (!recipe.instructions && !recipe.prompt) {
throw new Error('Recipe must have either instructions or prompt');
}
return recipe;
@@ -48,11 +48,13 @@ interface CleanExtension {
bundled?: boolean;
}
// TODO: This 'Recipe' interface should be converted to match the OpenAPI spec for Recipe
// once we have separated the recipe from the schedule in the frontend.
// Interface for clean recipe in YAML
interface CleanRecipe {
title: string;
description: string;
instructions: string;
instructions?: string;
prompt?: string;
activities?: string[];
extensions?: CleanExtension[];
@@ -131,9 +133,12 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
const cleanRecipe: CleanRecipe = {
title: recipe.title,
description: recipe.description,
instructions: recipe.instructions,
};
if (recipe.instructions) {
cleanRecipe.instructions = recipe.instructions;
}
if (recipe.prompt) {
cleanRecipe.prompt = recipe.prompt;
}
@@ -211,7 +216,7 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
}
// Add common optional fields
if (ext.env_keys && ext.env_keys.length > 0) {
if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) {
cleanExt.env_keys = ext.env_keys;
}
@@ -244,7 +249,10 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
}
if (recipe.author) {
cleanRecipe.author = recipe.author;
cleanRecipe.author = {
contact: recipe.author.contact || undefined,
metadata: recipe.author.metadata || undefined,
};
}
// Add schedule configuration based on execution mode
+97 -101
View File
@@ -1,39 +1,36 @@
import { Message } from '../types/message';
import { getApiUrl } from '../config';
import { FullExtensionConfig } from '../extensions';
import { safeJsonParse } from '../utils/jsonUtils';
import {
createRecipe as apiCreateRecipe,
encodeRecipe as apiEncodeRecipe,
decodeRecipe as apiDecodeRecipe,
} from '../api';
import type {
CreateRecipeRequest as ApiCreateRecipeRequest,
CreateRecipeResponse as ApiCreateRecipeResponse,
RecipeParameter,
Message as ApiMessage,
Role,
MessageContent,
} from '../api';
import type { Message as FrontendMessage } from '../types/message';
export interface Parameter {
key: string;
description: string;
input_type: string;
default?: string;
requirement: 'required' | 'optional' | 'user_prompt';
}
export interface Recipe {
title: string;
description: string;
instructions: string;
prompt?: string;
activities?: string[];
parameters?: Parameter[];
author?: {
contact?: string;
metadata?: string;
};
extensions?: FullExtensionConfig[];
goosehints?: string;
context?: string[];
profile?: string;
mcps?: number;
// Re-export OpenAPI types with frontend-specific additions
export type Parameter = RecipeParameter;
export type Recipe = import('../api').Recipe & {
// TODO: Separate these from the raw recipe type
// Properties added for scheduled execution
scheduledJobId?: string;
isScheduledExecution?: boolean;
}
// TODO: Separate these from the raw recipe type
// Legacy frontend properties (not in OpenAPI schema)
profile?: string;
goosehints?: string;
mcps?: number;
};
// Create frontend-compatible type that accepts frontend Message until we can refactor.
export interface CreateRecipeRequest {
messages: Message[];
// TODO: Fix this type to match Message OpenAPI spec
messages: FrontendMessage[];
title: string;
description: string;
activities?: string[];
@@ -43,98 +40,97 @@ export interface CreateRecipeRequest {
};
}
export interface CreateRecipeResponse {
recipe: Recipe | null;
error: string | null;
export type CreateRecipeResponse = ApiCreateRecipeResponse;
function convertFrontendMessageToApiMessage(frontendMessage: FrontendMessage): ApiMessage {
// TODO: Fix this type to match Message OpenAPI spec
return {
id: frontendMessage.id,
role: frontendMessage.role as Role,
content: frontendMessage.content.map((content) => ({
...content,
// Convert toolCall to match API expectations
...(content.type === 'toolRequest' && 'toolCall' in content
? {
toolCall: content.toolCall as unknown as { [key: string]: unknown },
}
: {}),
})) as MessageContent[],
created: frontendMessage.created,
};
}
export async function createRecipe(request: CreateRecipeRequest): Promise<CreateRecipeResponse> {
const url = getApiUrl('/recipes/create');
console.log('Creating recipe at:', url);
console.log('Request:', JSON.stringify(request, null, 2));
console.log('Creating recipe with request:', JSON.stringify(request, null, 2));
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
try {
const apiRequest: ApiCreateRecipeRequest = {
messages: request.messages.map(convertFrontendMessageToApiMessage),
title: request.title,
description: request.description,
activities: request.activities || undefined,
author: request.author
? {
contact: request.author.contact || undefined,
metadata: request.author.metadata || undefined,
}
: undefined,
};
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to create recipe:', {
status: response.status,
statusText: response.statusText,
error: errorText,
const response = await apiCreateRecipe({
body: apiRequest,
});
throw new Error(`Failed to create recipe: ${response.statusText} (${errorText})`);
if (!response.data) {
throw new Error('No data returned from API');
}
return response.data;
} catch (error) {
console.error('Failed to create recipe:', error);
throw error;
}
return safeJsonParse<CreateRecipeResponse>(response, 'Server failed to create recipe:');
}
export interface EncodeRecipeRequest {
recipe: Recipe;
}
export interface EncodeRecipeResponse {
deeplink: string;
}
export interface DecodeRecipeRequest {
deeplink: string;
}
export interface DecodeRecipeResponse {
recipe: Recipe;
}
export async function encodeRecipe(recipe: Recipe): Promise<string> {
const url = getApiUrl('/recipes/encode');
try {
const response = await apiEncodeRecipe({
body: { recipe },
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipe } as EncodeRecipeRequest),
});
if (!response.data) {
throw new Error('No data returned from API');
}
if (!response.ok) {
throw new Error(`Failed to encode recipe: ${response.status} ${response.statusText}`);
return response.data.deeplink;
} catch (error) {
console.error('Failed to encode recipe:', error);
throw error;
}
const data: EncodeRecipeResponse = await response.json();
return data.deeplink;
}
export async function decodeRecipe(deeplink: string): Promise<Recipe> {
const url = getApiUrl('/recipes/decode');
console.log('Decoding recipe from deeplink:', deeplink);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ deeplink } as DecodeRecipeRequest),
});
if (!response.ok) {
console.error('Failed to decode deeplink:', {
status: response.status,
statusText: response.statusText,
try {
const response = await apiDecodeRecipe({
body: { deeplink },
});
throw new Error(`Failed to decode deeplink: ${response.status} ${response.statusText}`);
}
const data: DecodeRecipeResponse = await response.json();
if (!data.recipe) {
console.error('Decoded recipe is null:', data);
throw new Error('Decoded recipe is null');
if (!response.data) {
throw new Error('No data returned from API');
}
if (!response.data.recipe) {
console.error('Decoded recipe is null:', response.data);
throw new Error('Decoded recipe is null');
}
return response.data.recipe as Recipe;
} catch (error) {
console.error('Failed to decode deeplink:', error);
throw error;
}
return data.recipe;
}
export async function generateDeepLink(recipe: Recipe): Promise<string> {
+11 -7
View File
@@ -100,8 +100,12 @@ export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Pr
}
// Validate recipe has required fields
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Recipe is missing required fields (title, description, instructions)');
if (!recipe.title || !recipe.description) {
throw new Error('Recipe is missing required fields (title, description)');
}
if (!recipe.instructions && !recipe.prompt) {
throw new Error('Recipe must have either instructions or prompt');
}
try {
@@ -142,14 +146,14 @@ export async function loadRecipe(recipeName: string, isGlobal: boolean): Promise
}
// Validate the loaded recipe has required fields
if (
!savedRecipe.recipe.title ||
!savedRecipe.recipe.description ||
!savedRecipe.recipe.instructions
) {
if (!savedRecipe.recipe.title || !savedRecipe.recipe.description) {
throw new Error('Loaded recipe is missing required fields');
}
if (!savedRecipe.recipe.instructions && !savedRecipe.recipe.prompt) {
throw new Error('Loaded recipe must have either instructions or prompt');
}
return savedRecipe.recipe;
} catch (error) {
throw new Error(